Compare commits

..

No commits in common. "7fdcf01534f2ffdc61c8156ae117edf5e2c4e948" and "23ebb2b09fe63ebe2961bd9cfb28e14dfd7703d7" have entirely different histories.

3 changed files with 23 additions and 56 deletions

View file

@ -5,7 +5,7 @@ EXE := $(shell go env GOEXE)
OCGO_BIN := bin/ocgo$(EXE) OCGO_BIN := bin/ocgo$(EXE)
GOBIN := $(shell go env GOBIN) GOBIN := $(shell go env GOBIN)
GOPATH := $(shell go env GOPATH) GOPATH := $(shell go env GOPATH)
INSTALL_DIR := $(HOME)/.local/bin INSTALL_DIR := $(if $(GOBIN),$(GOBIN),$(GOPATH)/bin)
build: build:
go build -ldflags "-X main.version=$(VERSION)" -o $(OCGO_BIN) ./cmd/ocgo go build -ldflags "-X main.version=$(VERSION)" -o $(OCGO_BIN) ./cmd/ocgo

View file

@ -35,7 +35,6 @@ type Config struct {
APIKey string `json:"api_key"` APIKey string `json:"api_key"`
Host string `json:"host"` Host string `json:"host"`
Port int `json:"port"` Port int `json:"port"`
Model string `json:"model"`
} }
type AnthropicRequest struct { type AnthropicRequest struct {
@ -203,13 +202,6 @@ func modelContextWindow(model string) int {
} }
} }
func modelDisplayName(model string) string {
if modelContextWindow(model) >= 1000000 {
return model + "[1M]"
}
return model
}
func modelSupportsImages(model string) bool { func modelSupportsImages(model string) bool {
switch model { switch model {
case "kimi-k2.6", "kimi-k2.5", "mimo-v2-omni": case "kimi-k2.6", "kimi-k2.5", "mimo-v2-omni":
@ -307,22 +299,17 @@ func launchCmd() *cobra.Command {
func serveCmd() *cobra.Command { func serveCmd() *cobra.Command {
var background bool 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 { cmd := &cobra.Command{Use: "serve", Short: "Start local Anthropic-compatible proxy", RunE: func(cmd *cobra.Command, args []string) error {
if background { if background {
return startBackground(model) return startBackground()
} }
cfg, err := loadConfig() cfg, err := loadConfig()
if err != nil { if err != nil {
return err return err
} }
if model != "" {
cfg.Model = model
}
return runServer(cfg) return runServer(cfg)
}} }}
cmd.Flags().BoolVarP(&background, "background", "b", false, "Run proxy in the background") 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 return cmd
} }
@ -397,7 +384,7 @@ func proxyMessages(w http.ResponseWriter, r *http.Request, cfg Config) {
http.Error(w, "invalid request", http.StatusBadRequest) http.Error(w, "invalid request", http.StatusBadRequest)
return return
} }
or := convertRequest(ar, cfg.Model) or := convertRequest(ar)
if err := validateImageSupport(or); err != nil { if err := validateImageSupport(or); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest) http.Error(w, err.Error(), http.StatusBadRequest)
return return
@ -422,10 +409,10 @@ func proxyMessages(w http.ResponseWriter, r *http.Request, cfg Config) {
return return
} }
if ar.Stream { if ar.Stream {
streamAnthropic(w, resp.Body, modelDisplayName(or.Model)) streamAnthropic(w, resp.Body, or.Model)
return return
} }
writeAnthropicResponse(w, resp.Body, modelDisplayName(or.Model)) writeAnthropicResponse(w, resp.Body, or.Model)
} }
func proxyChatCompletions(w http.ResponseWriter, r *http.Request, cfg Config) { func proxyChatCompletions(w http.ResponseWriter, r *http.Request, cfg Config) {
@ -443,17 +430,6 @@ func proxyChatCompletions(w http.ResponseWriter, r *http.Request, cfg Config) {
http.Error(w, err.Error(), http.StatusBadRequest) http.Error(w, err.Error(), http.StatusBadRequest)
return 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)) req, err := http.NewRequestWithContext(r.Context(), http.MethodPost, openAIURL, bytes.NewReader(body))
if err != nil { if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)
@ -482,7 +458,7 @@ func proxyResponses(w http.ResponseWriter, r *http.Request, cfg Config) {
http.Error(w, "invalid request", http.StatusBadRequest) http.Error(w, "invalid request", http.StatusBadRequest)
return return
} }
or := responsesToChat(rr, cfg.Model) or := responsesToChat(rr)
if err := validateImageSupport(or); err != nil { if err := validateImageSupport(or); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest) http.Error(w, err.Error(), http.StatusBadRequest)
return return
@ -507,10 +483,10 @@ func proxyResponses(w http.ResponseWriter, r *http.Request, cfg Config) {
return return
} }
if rr.Stream { if rr.Stream {
streamResponses(w, resp.Body, modelDisplayName(or.Model)) streamResponses(w, resp.Body, or.Model)
return return
} }
writeResponsesResponse(w, resp.Body, modelDisplayName(or.Model)) writeResponsesResponse(w, resp.Body, or.Model)
} }
func copyHeaders(dst, src http.Header) { func copyHeaders(dst, src http.Header) {
@ -625,12 +601,9 @@ func stripRawChatImageDetails(req map[string]any) bool {
return changed return changed
} }
func convertRequest(ar AnthropicRequest, defaultModel string) OAIRequest { func convertRequest(ar AnthropicRequest) OAIRequest {
model := cleanModelName(ar.Model) model := cleanModelName(ar.Model)
if model == "" || strings.HasPrefix(model, "claude-") || defaultModel != "" { if model == "" || strings.HasPrefix(model, "claude-") {
model = cleanModelName(defaultModel)
}
if model == "" {
model = "kimi-k2.6" model = "kimi-k2.6"
} }
out := OAIRequest{Model: model, Stream: ar.Stream, StreamOptions: streamUsageOptions(ar.Stream), MaxTokens: ar.MaxTokens, Temperature: ar.Temperature, TopP: ar.TopP} out := OAIRequest{Model: model, Stream: ar.Stream, StreamOptions: streamUsageOptions(ar.Stream), MaxTokens: ar.MaxTokens, Temperature: ar.Temperature, TopP: ar.TopP}
@ -646,11 +619,8 @@ func convertRequest(ar AnthropicRequest, defaultModel string) OAIRequest {
return out return out
} }
func responsesToChat(rr ResponsesRequest, defaultModel string) OAIRequest { func responsesToChat(rr ResponsesRequest) OAIRequest {
model := cleanModelName(rr.Model) model := cleanModelName(rr.Model)
if model == "" || defaultModel != "" {
model = cleanModelName(defaultModel)
}
if model == "" { if model == "" {
model = "kimi-k2.6" model = "kimi-k2.6"
} }
@ -1422,7 +1392,7 @@ func ensureServer(base string) error {
if healthy(base) { if healthy(base) {
return nil return nil
} }
if err := startBackground(""); err != nil { if err := startBackground(); err != nil {
return err return err
} }
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
@ -1440,7 +1410,7 @@ func startLaunchServer(base string) (*exec.Cmd, error) {
if healthy(base) { if healthy(base) {
return nil, nil return nil, nil
} }
cmd, err := startServerProcess(false, "") cmd, err := startServerProcess(false)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -1475,12 +1445,12 @@ func healthy(base string) bool {
return resp.StatusCode == 200 return resp.StatusCode == 200
} }
func startBackground(model string) error { func startBackground() error {
_, err := startServerProcess(true, model) _, err := startServerProcess(true)
return err return err
} }
func startServerProcess(detached bool, model string) (*exec.Cmd, error) { func startServerProcess(detached bool) (*exec.Cmd, error) {
bin, err := os.Executable() bin, err := os.Executable()
if err != nil { if err != nil {
return nil, err return nil, err
@ -1489,9 +1459,6 @@ func startServerProcess(detached bool, model string) (*exec.Cmd, error) {
return nil, err return nil, err
} }
args := []string{"serve"} args := []string{"serve"}
if model != "" {
args = append(args, "--model", model)
}
cmd := exec.Command(bin, args...) cmd := exec.Command(bin, args...)
logf, err := os.OpenFile(filepath.Join(configDir(), "ocgo.log"), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644) logf, err := os.OpenFile(filepath.Join(configDir(), "ocgo.log"), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
if err != nil { if err != nil {

View file

@ -217,7 +217,7 @@ func TestResponsesInputPreservesImages(t *testing.T) {
func TestResponsesImageKeepsKimiModel(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"}]}]`)} 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" { if out.Model != "kimi-k2.6" {
t.Fatalf("image request should keep Kimi model, got %q", out.Model) 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) { 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"}]}]`)} 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") { if err := validateImageSupport(out); err == nil || !strings.Contains(err.Error(), "deepseek-v4-pro") {
t.Fatalf("DeepSeek image request should be rejected, got %v", err) 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) { 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 { if anthropic.StreamOptions == nil || !anthropic.StreamOptions.IncludeUsage {
t.Fatalf("anthropic conversion should request stream usage: %+v", anthropic.StreamOptions) 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 { if responses.StreamOptions == nil || !responses.StreamOptions.IncludeUsage {
t.Fatalf("responses conversion should request stream usage: %+v", responses.StreamOptions) 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 { if plain.StreamOptions != nil {
t.Fatalf("non-streaming conversion should not set stream options: %+v", plain.StreamOptions) 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) { 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" { if out.Model != "kimi-k2.6" {
t.Fatalf("image request should keep Kimi model, got %q", out.Model) 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) { 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") { if err := validateImageSupport(out); err == nil || !strings.Contains(err.Error(), "deepseek-v4-pro") {
t.Fatalf("DeepSeek image request should be rejected, got %v", err) t.Fatalf("DeepSeek image request should be rejected, got %v", err)
} }