Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,17 @@
### Features

- Add local amp search support (`webSearch2`) using the Copilot Responses API with `web_search` tool (`gpt-5-mini` by default), and page extraction (`extractWebPageContent`) via Jina Reader, so amp CLI web search works without a paid ampcode.com account
- Auto-upgrade models to the best available variant (e.g. `claude-opus-4.7` → `claude-opus-4.7-1m-internal`) based on upstream model list, enabling features like `effort: high` that require extended variants
- Forward `reasoning_effort` verbatim on OpenAI `/v1/chat/completions` requests instead of dropping it during smart-routing to `/responses`. Values like `xhigh`, `minimal`, and `none` now reach the model — accept any string and let upstream validate against the per-model `capabilities.supports.reasoning_effort` enum
- Honor `output_config.effort` on Anthropic `/v1/messages` requests routed to OpenAI Chat Completions, in addition to the existing Responses path

### Bug Fixes

- Stop flattening Anthropic `output_config.effort: "max"` to `"high"` when routing to OpenAI Responses — `max` (and `xhigh`) are first-class effort values per current Anthropic SDK and Copilot model capabilities
- Stop bucketing OpenAI Responses `reasoning.effort` into the lossy `thinking_budget` numeric when smart-routing to `/chat/completions`; forward the effort string directly

### Breaking Changes

- Remove model auto-upgrade and the `COPILOT2API_NO_MODEL_UPGRADE` env var. Requests for a base model id (e.g. `claude-opus-4.7`) are no longer silently rewritten to `-1m-internal` / `-1m` variants — pass the variant id explicitly if you want it

## [0.3.1] - 2026-04-26

Expand Down
7 changes: 0 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,12 +108,6 @@ Add to `~/.claude/settings.json`:
}
```

### Model Auto-Upgrade

copilot2api automatically upgrades models to the best available variant in the upstream model list. For example, if `claude-opus-4.7-1m-internal` is available, a request for `claude-opus-4.7` will automatically use it. This enables features like 1M context windows and `effort: high` that are only supported by extended variants.

The upgrade priority is: `-1m-internal` > `-1m` > base model.

## Usage with Codex

Add to `~/.codex/config.toml`:
Expand Down Expand Up @@ -268,7 +262,6 @@ Environment variables are used as defaults when flags are not provided:
| `COPILOT2API_PORT` | Server port | `7777` |
| `COPILOT2API_TOKEN_DIR` | Token storage directory | `~/.config/copilot2api` |
| `COPILOT2API_DEBUG` | Enable debug logging (`true`/`false`, `1`/`0`) | `false` |
| `COPILOT2API_NO_MODEL_UPGRADE` | Disable model auto-upgrade (`true`/`false`, `1`/`0`) | `false` |
| `JINA_API_KEY` | Jina API key for amp page extraction (optional) | — |

CLI flags take precedence over environment variables.
Expand Down
9 changes: 7 additions & 2 deletions anthropic/convert.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,13 @@ func ConvertAnthropicToOpenAI(req AnthropicMessagesRequest) (OpenAIChatCompletio
openAIReq.StreamOptions = &OpenAIStreamOptions{IncludeUsage: true}
}

// Map thinking configuration
if req.Thinking != nil && req.Thinking.BudgetTokens != nil {
// Reasoning effort: prefer output_config.effort (modern Anthropic enum)
// over thinking.budget_tokens (legacy numeric). Forward effort verbatim;
// fall back to bucketing the budget when only the legacy field is set.
if req.OutputConfig != nil && req.OutputConfig.Effort != "" {
effort := req.OutputConfig.Effort
openAIReq.ReasoningEffort = &effort
} else if req.Thinking != nil && req.Thinking.BudgetTokens != nil {
openAIReq.ThinkingBudget = req.Thinking.BudgetTokens
}

Expand Down
59 changes: 59 additions & 0 deletions anthropic/convert_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -484,6 +484,65 @@ func floatPtr(f float64) *float64 {
return &f
}

func TestConvertAnthropicToOpenAI_OutputConfigEffort(t *testing.T) {
// output_config.effort takes priority over thinking.budget_tokens and
// is forwarded verbatim to ReasoningEffort — no lossy bucketing.
budget := 4000
req := AnthropicMessagesRequest{
Model: "claude-sonnet-4.6",
MaxTokens: 1024,
Messages: []AnthropicMessage{
{Role: "user", Content: AnthropicContent{Text: stringPtr("hi")}},
},
OutputConfig: &AnthropicOutputConfig{Effort: "xhigh"},
Thinking: &AnthropicThinking{Type: "enabled", BudgetTokens: &budget},
}

openAIReq, err := ConvertAnthropicToOpenAI(req)
if err != nil {
t.Fatalf("Conversion failed: %v", err)
}

if openAIReq.ReasoningEffort == nil {
t.Fatal("ReasoningEffort should not be nil when output_config.effort is set")
}
if *openAIReq.ReasoningEffort != "xhigh" {
t.Errorf("ReasoningEffort = %q, want xhigh", *openAIReq.ReasoningEffort)
}
if openAIReq.ThinkingBudget != nil {
t.Errorf("ThinkingBudget should not be set when effort wins, got %d", *openAIReq.ThinkingBudget)
}
}

func TestConvertAnthropicToOpenAI_ThinkingBudgetFallback(t *testing.T) {
// When only legacy thinking.budget_tokens is present, it still flows
// through as ThinkingBudget — no effort string is invented.
budget := 8000
req := AnthropicMessagesRequest{
Model: "claude-sonnet-4.6",
MaxTokens: 1024,
Messages: []AnthropicMessage{
{Role: "user", Content: AnthropicContent{Text: stringPtr("hi")}},
},
Thinking: &AnthropicThinking{Type: "enabled", BudgetTokens: &budget},
}

openAIReq, err := ConvertAnthropicToOpenAI(req)
if err != nil {
t.Fatalf("Conversion failed: %v", err)
}

if openAIReq.ReasoningEffort != nil {
t.Errorf("ReasoningEffort should be nil when only budget is set, got %q", *openAIReq.ReasoningEffort)
}
if openAIReq.ThinkingBudget == nil {
t.Fatal("ThinkingBudget should pass through")
}
if *openAIReq.ThinkingBudget != 8000 {
t.Errorf("ThinkingBudget = %d, want 8000", *openAIReq.ThinkingBudget)
}
}

func TestConvertOpenAIToAnthropic_SplitChoices(t *testing.T) {
// Copilot API returns split choices for Claude models:
// text in choice 0, tool_calls in choice 1
Expand Down
23 changes: 8 additions & 15 deletions anthropic/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,18 +19,16 @@ import (

// Handler handles Anthropic Messages API requests
type Handler struct {
upstream *upstream.Client
models *models.Cache
noModelUpgrade bool
upstream *upstream.Client
models *models.Cache
}

// NewHandler creates a new Anthropic handler.
// The transport is used for upstream HTTP requests (pass nil to create a new one).
func NewHandler(authClient upstream.TokenProvider, transport *http.Transport, mc *models.Cache, noModelUpgrade bool, debug bool) *Handler {
func NewHandler(authClient upstream.TokenProvider, transport *http.Transport, mc *models.Cache, debug bool) *Handler {
return &Handler{
upstream: upstream.NewClient(authClient, transport, debug),
models: mc,
noModelUpgrade: noModelUpgrade,
upstream: upstream.NewClient(authClient, transport, debug),
models: mc,
}
}

Expand Down Expand Up @@ -77,12 +75,7 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
slog.Info("anthropic request", "endpoint", "/v1/messages", "model", anthropicReq.Model, "stream", anthropicReq.Stream, "messages", len(anthropicReq.Messages), "route", route, "duration_ms", time.Since(start).Milliseconds())
}()

upgradedModel, modelInfo, capabilityFetchFailed := h.getModelInfoWithUpgrade(r.Context(), anthropicReq.Model, h.noModelUpgrade)
modelUpgraded := upgradedModel != anthropicReq.Model
if modelUpgraded {
slog.Debug("auto-upgraded model", "from", anthropicReq.Model, "to", upgradedModel)
anthropicReq.Model = upgradedModel
}
modelInfo, capabilityFetchFailed := h.getModelInfo(r.Context(), anthropicReq.Model)

if modelSupportsEndpoint(modelInfo, "/v1/messages") {
route = "native"
Expand All @@ -92,8 +85,8 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// Only re-encode the body for native passthrough (the only path that
// sends raw reqBody). Responses and Chat Completions paths use the
// parsed struct, so they skip this JSON round-trip.
if modelChanged || modelUpgraded || cacheControlInfo.ScopeCount > 0 || topLevelInfo.HasContextManagement {
newBody, err := normalizeNativeMessagesBody(reqBody, anthropicReq.Model, modelChanged || modelUpgraded)
if modelChanged || cacheControlInfo.ScopeCount > 0 || topLevelInfo.HasContextManagement {
newBody, err := normalizeNativeMessagesBody(reqBody, anthropicReq.Model, modelChanged)
if err != nil {
WriteAnthropicError(w, http.StatusBadRequest, AnthropicErrorTypeInvalidRequest, fmt.Sprintf("Invalid JSON: %v", err))
return
Expand Down
33 changes: 6 additions & 27 deletions anthropic/models.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,37 +53,16 @@ func resolveModelAlias(modelID string) string {
return modelID
}

// modelUpgradeSuffixes lists suffixes to try (in order) when upgrading a model
// to the best available variant. The first match wins.
var modelUpgradeSuffixes = []string{"-1m-internal", "-1m"}

// upgradeModel returns the best available variant of modelID by checking for
// known suffixes in the upstream model list. Returns the original if no better variant exists.
func upgradeModel(modelID string, available map[string]*models.Info) string {
for _, suffix := range modelUpgradeSuffixes {
candidate := modelID + suffix
if _, ok := available[candidate]; ok {
slog.Debug("auto-upgraded model", "from", modelID, "to", candidate)
return candidate
}
}
return modelID
}

// getModelInfoWithUpgrade fetches model info and auto-upgrades the model
// to the best available variant (e.g. appending "-1m-internal" if available).
// Set skipUpgrade to true to disable auto-upgrade.
func (h *Handler) getModelInfoWithUpgrade(ctx context.Context, modelID string, skipUpgrade bool) (string, *models.Info, bool) {
// getModelInfo fetches model info for the given model ID. The returned bool
// reports whether the upstream models fetch failed (in which case callers
// should fall back to a default route).
func (h *Handler) getModelInfo(ctx context.Context, modelID string) (*models.Info, bool) {
infoMap, err := h.models.GetInfo(ctx)
if err != nil {
slog.Error("failed to fetch models for capability detection", "error", err)
return modelID, nil, true
}

if !skipUpgrade {
modelID = upgradeModel(modelID, infoMap)
return nil, true
}
return modelID, infoMap[modelID], false
return infoMap[modelID], false
}

func modelSupportsEndpoint(info *models.Info, endpoint string) bool {
Expand Down
29 changes: 0 additions & 29 deletions anthropic/models_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -82,32 +82,3 @@ func TestResolveModelAlias(t *testing.T) {
})
}
}

func TestUpgradeModel(t *testing.T) {
available := map[string]*models.Info{
"claude-opus-4.7": {ID: "claude-opus-4.7"},
"claude-opus-4.7-1m-internal": {ID: "claude-opus-4.7-1m-internal"},
"claude-opus-4.6": {ID: "claude-opus-4.6"},
"claude-opus-4.6-1m": {ID: "claude-opus-4.6-1m"},
"claude-sonnet-4.6": {ID: "claude-sonnet-4.6"},
}

tests := []struct {
input string
want string
}{
{"claude-opus-4.7", "claude-opus-4.7-1m-internal"},
{"claude-opus-4.6", "claude-opus-4.6-1m"},
{"claude-sonnet-4.6", "claude-sonnet-4.6"},
{"claude-sonnet-5.0", "claude-sonnet-5.0"},
}

for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
got := upgradeModel(tt.input, available)
if got != tt.want {
t.Errorf("upgradeModel(%q) = %q, want %q", tt.input, got, tt.want)
}
})
}
}
9 changes: 4 additions & 5 deletions anthropic/responses_convert.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,12 @@ import (

// resolveReasoningEffort determines the reasoning effort level for the Responses API.
// OutputConfig.Effort takes priority when set; otherwise falls back to thinking budget.
// Effort strings are forwarded verbatim — the upstream model's
// capabilities.supports.reasoning_effort enum is the source of truth for what's
// accepted (e.g. xhigh on GPT-5, max on Anthropic, minimal on Gemini Flash).
func resolveReasoningEffort(thinking *AnthropicThinking, outputConfig *AnthropicOutputConfig) string {
if outputConfig != nil && outputConfig.Effort != "" {
effort := outputConfig.Effort
if effort == "max" {
effort = "high" // Responses API doesn't support "max"
}
return effort
return outputConfig.Effort
}
return thinkingEffort(thinking)
}
Expand Down
3 changes: 2 additions & 1 deletion anthropic/responses_convert_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,8 @@ func TestResolveReasoningEffort(t *testing.T) {
{"output_config low", nil, effort("low"), "low"},
{"output_config medium", nil, effort("medium"), "medium"},
{"output_config high", nil, effort("high"), "high"},
{"output_config max maps to high", nil, effort("max"), "high"},
{"output_config max forwarded verbatim", nil, effort("max"), "max"},
{"output_config xhigh forwarded verbatim", nil, effort("xhigh"), "xhigh"},
{"output_config overrides thinking", budget(4000), effort("high"), "high"},
{"output_config empty falls back to thinking", budget(4000), &AnthropicOutputConfig{}, "low"},
}
Expand Down
2 changes: 1 addition & 1 deletion anthropic/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ type AnthropicThinking struct {

// AnthropicOutputConfig represents output configuration
type AnthropicOutputConfig struct {
Effort string `json:"effort,omitempty"` // "low", "medium", "high", "max"
Effort string `json:"effort,omitempty"` // "low", "medium", "high", "xhigh", "max"
}

// AnthropicSystem can be string or []AnthropicTextBlock
Expand Down
5 changes: 5 additions & 0 deletions internal/types/openai.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,11 @@ type OpenAIChatCompletionsRequest struct {
User string `json:"user,omitempty"`
Metadata map[string]string `json:"metadata,omitempty"`
ThinkingBudget *int `json:"thinking_budget,omitempty"`
// ReasoningEffort is forwarded verbatim. Per-model vocabulary varies
// (see /v1/models capabilities.supports.reasoning_effort): GPT-5 series
// accepts none|minimal|low|medium|high|xhigh; Gemini Flash adds minimal
// without xhigh. Pass any string through and let upstream validate.
ReasoningEffort *string `json:"reasoning_effort,omitempty"`
ReasoningText *string `json:"reasoning_text,omitempty"`
ReasoningOpaque *string `json:"reasoning_opaque,omitempty"`
ResponseFormat *ResponseFormat `json:"response_format,omitempty"`
Expand Down
8 changes: 1 addition & 7 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -125,13 +125,7 @@ func main() {
proxyHandler := proxy.NewHandler(authClient, transport, modelsCache, *debug)

// Initialize Anthropic handler
noModelUpgrade := false
if v := os.Getenv("COPILOT2API_NO_MODEL_UPGRADE"); v != "" {
if enabled, err := strconv.ParseBool(v); err == nil {
noModelUpgrade = enabled
}
}
anthropicHandler := anthropic.NewHandler(authClient, transport, modelsCache, noModelUpgrade, *debug)
anthropicHandler := anthropic.NewHandler(authClient, transport, modelsCache, *debug)

// Initialize Gemini handler
geminiHandler := gemini.NewHandler(authClient, transport, modelsCache, *debug)
Expand Down
21 changes: 15 additions & 6 deletions proxy/convert.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,8 +77,15 @@ func ConvertChatToResponsesRequest(req types.OpenAIChatCompletionsRequest) types
result.MaxOutputTokens = &v
}

// Thinking budget → reasoning
if req.ThinkingBudget != nil {
// Reasoning effort: prefer the modern enum string when the client sends
// it directly. Fall back to bucketing thinking_budget for older clients
// that only know the numeric field.
if req.ReasoningEffort != nil && *req.ReasoningEffort != "" {
result.Reasoning = &types.ResponseReasoning{
Effort: *req.ReasoningEffort,
Summary: "detailed",
}
} else if req.ThinkingBudget != nil {
result.Reasoning = &types.ResponseReasoning{
Effort: thinkingBudgetToEffort(*req.ThinkingBudget),
Summary: "detailed",
Expand Down Expand Up @@ -499,10 +506,12 @@ func ConvertResponsesToChatRequest(req types.ResponsesRequest) types.OpenAIChatC
result.MaxTokens = &v
}

// Reasoning → thinking budget
if req.Reasoning != nil {
budget := effortToThinkingBudget(req.Reasoning.Effort)
result.ThinkingBudget = &budget
// Reasoning effort: forward the enum string directly. Chat Completions
// accepts reasoning_effort natively, so we don't bucket it back into the
// lossy thinking_budget representation.
if req.Reasoning != nil && req.Reasoning.Effort != "" {
effort := req.Reasoning.Effort
result.ReasoningEffort = &effort
}

// text.format → response_format
Expand Down
Loading