Compare commits
2 commits
23ebb2b09f
...
7fdcf01534
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7fdcf01534 | ||
|
|
582913dfcc |
3 changed files with 56 additions and 23 deletions
2
Makefile
2
Makefile
|
|
@ -5,7 +5,7 @@ EXE := $(shell go env GOEXE)
|
|||
OCGO_BIN := bin/ocgo$(EXE)
|
||||
GOBIN := $(shell go env GOBIN)
|
||||
GOPATH := $(shell go env GOPATH)
|
||||
INSTALL_DIR := $(if $(GOBIN),$(GOBIN),$(GOPATH)/bin)
|
||||
INSTALL_DIR := $(HOME)/.local/bin
|
||||
|
||||
build:
|
||||
go build -ldflags "-X main.version=$(VERSION)" -o $(OCGO_BIN) ./cmd/ocgo
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ type Config struct {
|
|||
APIKey string `json:"api_key"`
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"`
|
||||
Model string `json:"model"`
|
||||
}
|
||||
|
||||
type AnthropicRequest struct {
|
||||
|
|
@ -202,6 +203,13 @@ func modelContextWindow(model string) int {
|
|||
}
|
||||
}
|
||||
|
||||
func modelDisplayName(model string) string {
|
||||
if modelContextWindow(model) >= 1000000 {
|
||||
return model + "[1M]"
|
||||
}
|
||||
return model
|
||||
}
|
||||
|
||||
func modelSupportsImages(model string) bool {
|
||||
switch model {
|
||||
case "kimi-k2.6", "kimi-k2.5", "mimo-v2-omni":
|
||||
|
|
@ -299,17 +307,22 @@ func launchCmd() *cobra.Command {
|
|||
|
||||
func serveCmd() *cobra.Command {
|
||||
var background bool
|
||||
var model string
|
||||
cmd := &cobra.Command{Use: "serve", Short: "Start local Anthropic-compatible proxy", RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if background {
|
||||
return startBackground()
|
||||
return startBackground(model)
|
||||
}
|
||||
cfg, err := loadConfig()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if model != "" {
|
||||
cfg.Model = model
|
||||
}
|
||||
return runServer(cfg)
|
||||
}}
|
||||
cmd.Flags().BoolVarP(&background, "background", "b", false, "Run proxy in the background")
|
||||
cmd.Flags().StringVarP(&model, "model", "m", "", "Upstream model ID (default: kimi-k2.6)")
|
||||
return cmd
|
||||
}
|
||||
|
||||
|
|
@ -384,7 +397,7 @@ func proxyMessages(w http.ResponseWriter, r *http.Request, cfg Config) {
|
|||
http.Error(w, "invalid request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
or := convertRequest(ar)
|
||||
or := convertRequest(ar, cfg.Model)
|
||||
if err := validateImageSupport(or); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
|
|
@ -409,10 +422,10 @@ func proxyMessages(w http.ResponseWriter, r *http.Request, cfg Config) {
|
|||
return
|
||||
}
|
||||
if ar.Stream {
|
||||
streamAnthropic(w, resp.Body, or.Model)
|
||||
streamAnthropic(w, resp.Body, modelDisplayName(or.Model))
|
||||
return
|
||||
}
|
||||
writeAnthropicResponse(w, resp.Body, or.Model)
|
||||
writeAnthropicResponse(w, resp.Body, modelDisplayName(or.Model))
|
||||
}
|
||||
|
||||
func proxyChatCompletions(w http.ResponseWriter, r *http.Request, cfg Config) {
|
||||
|
|
@ -430,6 +443,17 @@ func proxyChatCompletions(w http.ResponseWriter, r *http.Request, cfg Config) {
|
|||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
// Apply default model if configured
|
||||
if cfg.Model != "" {
|
||||
dm := cleanModelName(cfg.Model)
|
||||
var reqMap map[string]any
|
||||
if json.Unmarshal(body, &reqMap) == nil {
|
||||
if m, _ := reqMap["model"].(string); m == "" || strings.HasPrefix(m, "claude-") {
|
||||
reqMap["model"] = dm
|
||||
body, _ = json.Marshal(reqMap)
|
||||
}
|
||||
}
|
||||
}
|
||||
req, err := http.NewRequestWithContext(r.Context(), http.MethodPost, openAIURL, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
|
|
@ -458,7 +482,7 @@ func proxyResponses(w http.ResponseWriter, r *http.Request, cfg Config) {
|
|||
http.Error(w, "invalid request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
or := responsesToChat(rr)
|
||||
or := responsesToChat(rr, cfg.Model)
|
||||
if err := validateImageSupport(or); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
|
|
@ -483,10 +507,10 @@ func proxyResponses(w http.ResponseWriter, r *http.Request, cfg Config) {
|
|||
return
|
||||
}
|
||||
if rr.Stream {
|
||||
streamResponses(w, resp.Body, or.Model)
|
||||
streamResponses(w, resp.Body, modelDisplayName(or.Model))
|
||||
return
|
||||
}
|
||||
writeResponsesResponse(w, resp.Body, or.Model)
|
||||
writeResponsesResponse(w, resp.Body, modelDisplayName(or.Model))
|
||||
}
|
||||
|
||||
func copyHeaders(dst, src http.Header) {
|
||||
|
|
@ -601,9 +625,12 @@ func stripRawChatImageDetails(req map[string]any) bool {
|
|||
return changed
|
||||
}
|
||||
|
||||
func convertRequest(ar AnthropicRequest) OAIRequest {
|
||||
func convertRequest(ar AnthropicRequest, defaultModel string) OAIRequest {
|
||||
model := cleanModelName(ar.Model)
|
||||
if model == "" || strings.HasPrefix(model, "claude-") {
|
||||
if model == "" || strings.HasPrefix(model, "claude-") || defaultModel != "" {
|
||||
model = cleanModelName(defaultModel)
|
||||
}
|
||||
if model == "" {
|
||||
model = "kimi-k2.6"
|
||||
}
|
||||
out := OAIRequest{Model: model, Stream: ar.Stream, StreamOptions: streamUsageOptions(ar.Stream), MaxTokens: ar.MaxTokens, Temperature: ar.Temperature, TopP: ar.TopP}
|
||||
|
|
@ -619,8 +646,11 @@ func convertRequest(ar AnthropicRequest) OAIRequest {
|
|||
return out
|
||||
}
|
||||
|
||||
func responsesToChat(rr ResponsesRequest) OAIRequest {
|
||||
func responsesToChat(rr ResponsesRequest, defaultModel string) OAIRequest {
|
||||
model := cleanModelName(rr.Model)
|
||||
if model == "" || defaultModel != "" {
|
||||
model = cleanModelName(defaultModel)
|
||||
}
|
||||
if model == "" {
|
||||
model = "kimi-k2.6"
|
||||
}
|
||||
|
|
@ -1392,7 +1422,7 @@ func ensureServer(base string) error {
|
|||
if healthy(base) {
|
||||
return nil
|
||||
}
|
||||
if err := startBackground(); err != nil {
|
||||
if err := startBackground(""); err != nil {
|
||||
return err
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
|
|
@ -1410,7 +1440,7 @@ func startLaunchServer(base string) (*exec.Cmd, error) {
|
|||
if healthy(base) {
|
||||
return nil, nil
|
||||
}
|
||||
cmd, err := startServerProcess(false)
|
||||
cmd, err := startServerProcess(false, "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
@ -1445,12 +1475,12 @@ func healthy(base string) bool {
|
|||
return resp.StatusCode == 200
|
||||
}
|
||||
|
||||
func startBackground() error {
|
||||
_, err := startServerProcess(true)
|
||||
func startBackground(model string) error {
|
||||
_, err := startServerProcess(true, model)
|
||||
return err
|
||||
}
|
||||
|
||||
func startServerProcess(detached bool) (*exec.Cmd, error) {
|
||||
func startServerProcess(detached bool, model string) (*exec.Cmd, error) {
|
||||
bin, err := os.Executable()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
|
@ -1459,6 +1489,9 @@ func startServerProcess(detached bool) (*exec.Cmd, error) {
|
|||
return nil, err
|
||||
}
|
||||
args := []string{"serve"}
|
||||
if model != "" {
|
||||
args = append(args, "--model", model)
|
||||
}
|
||||
cmd := exec.Command(bin, args...)
|
||||
logf, err := os.OpenFile(filepath.Join(configDir(), "ocgo.log"), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
|
||||
if err != nil {
|
||||
|
|
|
|||
|
|
@ -217,7 +217,7 @@ func TestResponsesInputPreservesImages(t *testing.T) {
|
|||
|
||||
func TestResponsesImageKeepsKimiModel(t *testing.T) {
|
||||
req := ResponsesRequest{Model: "kimi-k2.6", Input: []byte(`[{"type":"message","role":"user","content":[{"type":"input_text","text":"describe this"},{"type":"input_image","image_url":"data:image/png;base64,abc"}]}]`)}
|
||||
out := responsesToChat(req)
|
||||
out := responsesToChat(req, "")
|
||||
if out.Model != "kimi-k2.6" {
|
||||
t.Fatalf("image request should keep Kimi model, got %q", out.Model)
|
||||
}
|
||||
|
|
@ -228,7 +228,7 @@ func TestResponsesImageKeepsKimiModel(t *testing.T) {
|
|||
|
||||
func TestResponsesImageRejectsUnsupportedModel(t *testing.T) {
|
||||
req := ResponsesRequest{Model: "deepseek-v4-pro", Input: []byte(`[{"type":"message","role":"user","content":[{"type":"input_text","text":"describe this"},{"type":"input_image","image_url":"data:image/png;base64,abc"}]}]`)}
|
||||
out := responsesToChat(req)
|
||||
out := responsesToChat(req, "")
|
||||
if err := validateImageSupport(out); err == nil || !strings.Contains(err.Error(), "deepseek-v4-pro") {
|
||||
t.Fatalf("DeepSeek image request should be rejected, got %v", err)
|
||||
}
|
||||
|
|
@ -273,15 +273,15 @@ func TestRawChatStreamRequestsUsage(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestConvertedStreamingRequestsAskForUsage(t *testing.T) {
|
||||
anthropic := convertRequest(AnthropicRequest{Model: "kimi-k2.6", Stream: true, Messages: []AMessage{{Role: "user", Content: []byte(`hello`)}}})
|
||||
anthropic := convertRequest(AnthropicRequest{Model: "kimi-k2.6", Stream: true, Messages: []AMessage{{Role: "user", Content: []byte(`hello`)}}}, "")
|
||||
if anthropic.StreamOptions == nil || !anthropic.StreamOptions.IncludeUsage {
|
||||
t.Fatalf("anthropic conversion should request stream usage: %+v", anthropic.StreamOptions)
|
||||
}
|
||||
responses := responsesToChat(ResponsesRequest{Model: "kimi-k2.6", Stream: true, Input: []byte(`"hello"`)})
|
||||
responses := responsesToChat(ResponsesRequest{Model: "kimi-k2.6", Stream: true, Input: []byte(`"hello"`)}, "")
|
||||
if responses.StreamOptions == nil || !responses.StreamOptions.IncludeUsage {
|
||||
t.Fatalf("responses conversion should request stream usage: %+v", responses.StreamOptions)
|
||||
}
|
||||
plain := responsesToChat(ResponsesRequest{Model: "kimi-k2.6", Input: []byte(`"hello"`)})
|
||||
plain := responsesToChat(ResponsesRequest{Model: "kimi-k2.6", Input: []byte(`"hello"`)}, "")
|
||||
if plain.StreamOptions != nil {
|
||||
t.Fatalf("non-streaming conversion should not set stream options: %+v", plain.StreamOptions)
|
||||
}
|
||||
|
|
@ -305,7 +305,7 @@ func TestAnthropicContentPreservesImages(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestAnthropicImageKeepsKimiModel(t *testing.T) {
|
||||
out := convertRequest(AnthropicRequest{Model: "kimi-k2.6", Messages: []AMessage{{Role: "user", Content: []byte(`[{"type":"text","text":"what is this?"},{"type":"image","source":{"type":"base64","media_type":"image/png","data":"abc"}}]`)}}})
|
||||
out := convertRequest(AnthropicRequest{Model: "kimi-k2.6", Messages: []AMessage{{Role: "user", Content: []byte(`[{"type":"text","text":"what is this?"},{"type":"image","source":{"type":"base64","media_type":"image/png","data":"abc"}}]`)}}}, "")
|
||||
if out.Model != "kimi-k2.6" {
|
||||
t.Fatalf("image request should keep Kimi model, got %q", out.Model)
|
||||
}
|
||||
|
|
@ -315,7 +315,7 @@ func TestAnthropicImageKeepsKimiModel(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestAnthropicImageRejectsUnsupportedModel(t *testing.T) {
|
||||
out := convertRequest(AnthropicRequest{Model: "deepseek-v4-pro", Messages: []AMessage{{Role: "user", Content: []byte(`[{"type":"text","text":"what is this?"},{"type":"image","source":{"type":"base64","media_type":"image/png","data":"abc"}}]`)}}})
|
||||
out := convertRequest(AnthropicRequest{Model: "deepseek-v4-pro", Messages: []AMessage{{Role: "user", Content: []byte(`[{"type":"text","text":"what is this?"},{"type":"image","source":{"type":"base64","media_type":"image/png","data":"abc"}}]`)}}}, "")
|
||||
if err := validateImageSupport(out); err == nil || !strings.Contains(err.Error(), "deepseek-v4-pro") {
|
||||
t.Fatalf("DeepSeek image request should be rejected, got %v", err)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue