gateway: Codex's compaction on a CC Switch relay table that magpie points at /v1 completes, with a summary magpie makes, instead of failing "remote compaction v2 expected exactly one compaction output item, got 0 from 1 output items" (#1301, ylacmilan)
Codex compacts remotely (compaction v2: a compaction_trigger item on /responses, and exactly one compaction item back) for any provider table named "OpenAI" (codex-rs ProviderCapabilities::from_config, is_openai), and CC Switch names its relay tables so. While a magpie model is on, magpie points those tables at /v1 (takeTables). /v1/responses relayed the trigger as it came; the relay answered with a message, and Codex counted 0 compaction items out of 1. Only CodexPath turned the trigger into a summary request (codexInput, codexCompact). handle now does the same for a Responses request on /v1 that carries a compaction_trigger: the model summarises, and the summary goes back as magpie's compaction item. A later turn's magpie1: compaction item reaches the model as the summary's text, not as an item the relay can't open. TestCodexCompactionTriggerOnV1 fails without the change (one output_item.done of type "message", the relay's reply, where Codex needs one "compaction") and passes with it. Real use: codex-cli 0.160.1 in a sandbox CODEX_HOME with [model_providers.custom] name = "OpenAI", base_url = magpie's /v1 (built from this tree, `magpie serve` in a sandbox HOME on 127.0.0.1:3592), model token/gpt-6.1-sol on a fake Responses relay that reports 90K tokens and model_auto_compact_token_limit = 1000. Old build: `codex exec resume` printed the reporter's exact error, and the relay got the compaction_trigger. New build: "context compacted", the relay got Codex's compaction prompt, and the next two turns went on with the summary as a message. go test ./internal/gateway, go vet (darwin, GOOS=linux, GOOS=windows) pass. Not tried: the reporter's own relay and CC Switch version.
This commit is contained in:
1 parent
0d55d9f6c2
commit
9275abcddb
3 files changed
+72
-1
No files matched your search
@@ -55,7 +55,7 @@ served by a plugin are in [Provider and plugin ownership](provider-plugins.md).
|
||||
- An account resting out of its window (a Claude account by the time Claude Code's refusal named, `resets`, or any by `window`) is back once magpie's reading of its windows (`provider.Allowances`) finds it full till sooner: the window used below `SpentShareOf`, or its reset passed, with no other window over the same models still full. The reading tells `provider.OnRenewed` as the account's `UsageAgent` and user, and `renewed` lifts that account's rests out of quota or by `window`; its rests for a rate limit, its credit, a verification or a failure stay, as a key's do, and so does every rest of other accounts still out of their windows. Planning reads every account candidate's allowance, ordered groups included, so this needs no step of its own in `restingOf`.
|
||||
- Only an account is held at a usage cap (`capHeld`) or rested out of one model's pool (`pooled`); a key's windows order it and rest it, nothing more.
|
||||
- Codex offers its image tool (the `image_gen` namespace, #870) to every magpie provider. When a provider other than a ChatGPT sign-in or openai.com answers a bare 400, `attempt` asks it once more without the tool (`image_tool.go`). This happens both passed through and translated. If that works, the provider is marked unfit for the tool (`tool:image_gen`) and isn't offered it again. If it doesn't, the tool is restored and the 400 stands (#949).
|
||||
- A translated Responses reply gives Codex reasoning items magpie made: an `rs_` id and a summary, nothing sealed. Codex hands them back on every turn. A request relayed as is to OpenAI's API or Azure OpenAI goes without them (`withoutBareReasoning`) unless `store` is true, because both look the id up among stored items and answer 400 "Item with id 'rs_…' not found" (#1008). Any other Responses upstream gets them, because a DeepSeek model wants its reasoning back (#388). When one answers 400 naming the input (`refusesInput`: `param` is `input`, or the error names an item by id), as a relay in front of OpenAI does with a message of its own, `passthrough` asks once more without them and, once that works, leaves them out for that provider and model from then on (`bareReasoningRefused`, #1044). Codex's compaction offers no tools, so it is relayed even when its turns were translated. When a provider answers its summary request 404, or 400 for an item it didn't find (`itemNotFound`, a relay in front of either), `codexCompact` asks once more with the conversation as text (`plainCompact`), then compacts locally (#866, #1008). So does the ChatGPT backend's 502 "response protection is unavailable" (`protectionRefused`): a long history refused so was answered as text.
|
||||
- A translated Responses reply gives Codex reasoning items magpie made: an `rs_` id and a summary, nothing sealed. Codex hands them back on every turn. A request relayed as is to OpenAI's API or Azure OpenAI goes without them (`withoutBareReasoning`) unless `store` is true, because both look the id up among stored items and answer 400 "Item with id 'rs_…' not found" (#1008). Any other Responses upstream gets them, because a DeepSeek model wants its reasoning back (#388). When one answers 400 naming the input (`refusesInput`: `param` is `input`, or the error names an item by id), as a relay in front of OpenAI does with a message of its own, `passthrough` asks once more without them and, once that works, leaves them out for that provider and model from then on (`bareReasoningRefused`, #1044). Codex's compaction offers no tools, so it is relayed even when its turns were translated. When a provider answers its summary request 404, or 400 for an item it didn't find (`itemNotFound`, a relay in front of either), `codexCompact` asks once more with the conversation as text (`plainCompact`), then compacts locally (#866, #1008). So does the ChatGPT backend's 502 "response protection is unavailable" (`protectionRefused`): a long history refused so was answered as text. Codex also compacts remotely, a `compaction_trigger` on `/responses` that wants exactly one compaction item back, on any provider table named "OpenAI", as CC Switch names its relay tables. magpie points those at `/v1` while a magpie model is on, so `handle` makes the summary there too (`codexInput`, `codexCompact`), and magpie's summaries go back to the model as text (#1301).
|
||||
- Structured output is carried across every translation (`Format`, [`format.go`](../../internal/gateway/format.go)). It is read from Responses' `text.format`, Chat's `response_format`, Anthropic's `output_config.format` and Gemini's `responseMimeType` with `responseJsonSchema`/`responseSchema`: `json_schema` with its name, description, schema and strict, or `json_object`. Each upstream is asked in its own field: `text.format` on Responses, `response_format` on Chat, `output_config.format` on Messages, and `generationConfig.responseMimeType`/`responseJsonSchema` on Gemini and Code Assist. An API with no name for it gets `response`. Messages and the Claude subscription's `--json-schema` take a schema only, so `json_object` is told in the system prompt there (`inSystem`). The bridges to a vendor's own API (Cursor, Devin, Kiro, Qoder, Command Code, Zed) are always told in the system prompt. A 400 or 422 that names the field (`refusesFormat`: DeepSeek's "This response_format type is unavailable now", a schema Anthropic can't hold) marks the provider and model (`formatRefused`), and the request is asked again with the format told in the system prompt, as are later requests to that provider and model. A relayed request keeps its format as sent. A Gemini client's format is also still told in its system prompt, as before.
|
||||
- Group members are `provider/model[:effort]`. A member with a fixed effort is asked for that effort, bounded by the group's levels (#671).
|
||||
- An agent whose config can't carry an effort has one picked in magpie (`provider.AgentEffort`, settings' `agentEfforts`; Cursor Private Inference's row, #1003). It is asked for on that agent's requests to a model that reasons (`Provider.Thinks`), in place of the level the request sent, fitted to the model's levels and bounded by the group's. Order: a member's fixed effort, then a model's `:effort` suffix, then the turn's pick (a routing rule's), then the agent's pick, then the request's own. A model that doesn't reason is sent as asked; the default (none picked) leaves every request as it came. The pick beats the request's own because Cursor sends its own default as often as a real choice. Picking Default hands the choice back to Cursor.
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/yetone/magpie/internal/provider"
|
||||
)
|
||||
|
||||
// #1301: Codex compacts remotely (compaction v2: a compaction_trigger on
|
||||
// /responses, exactly one compaction item back) for any provider table
|
||||
// named "OpenAI", as CC Switch names its relay tables. magpie points those
|
||||
// tables at /v1 while a magpie model is on, so the trigger reached /v1, went
|
||||
// on to the relay as it came, and the relay's message came back: "remote
|
||||
// compaction v2 expected exactly one compaction output item, got 0 from 1
|
||||
// output items". On /v1 the summary is magpie's to make, as on Codex's
|
||||
// backend path, and magpie's summary goes back to the model as text.
|
||||
func TestCodexCompactionTriggerOnV1(t *testing.T) {
|
||||
f := &fake{t: t, reply: sse(
|
||||
`data: {"type":"response.created","response":{"id":"resp_r1","status":"in_progress","output":[]}}`,
|
||||
`data: {"type":"response.output_item.done","output_index":0,"item":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"SUMMARY"}]}}`,
|
||||
`data: {"type":"response.completed","response":{"id":"resp_r1","status":"completed","output":[]}}`)}
|
||||
setup(t, provider.Responses, f)
|
||||
code, body := post(t, "/v1/responses", `{"model":"fake/m1","instructions":"You are Codex","stream":true,"store":false,
|
||||
"include":["reasoning.encrypted_content"],"prompt_cache_key":"thread-1","tool_choice":"auto","parallel_tool_calls":true,
|
||||
"tools":[{"type":"function","name":"shell","parameters":{"type":"object"}}],
|
||||
"input":[{"type":"message","role":"user","content":[{"type":"input_text","text":"fix the bug"}]},{"type":"compaction_trigger"}]}`)
|
||||
if code != 200 {
|
||||
t.Fatalf("%d %s", code, body)
|
||||
}
|
||||
if strings.Contains(string(f.got), "compaction_trigger") || !strings.Contains(string(f.got), "CONTEXT CHECKPOINT COMPACTION") {
|
||||
t.Errorf("relay got %s", f.got)
|
||||
}
|
||||
// what Codex's collect_compaction_output counts: output_item.done items
|
||||
var done []map[string]any
|
||||
for _, e := range events(body) {
|
||||
if e["type"] == "response.output_item.done" {
|
||||
done = append(done, e["item"].(map[string]any))
|
||||
}
|
||||
}
|
||||
if len(done) != 1 || done[0]["type"] != "compaction" {
|
||||
t.Fatalf("Codex wants exactly one compaction output item, got %v\n%s", done, body)
|
||||
}
|
||||
enc, _ := done[0]["encrypted_content"].(string)
|
||||
if b, _ := base64.StdEncoding.DecodeString(strings.TrimPrefix(enc, magpieCompaction)); string(b) != "SUMMARY" {
|
||||
t.Errorf("compaction holds %q", enc)
|
||||
}
|
||||
|
||||
// the next turn carries the compaction item; the relay gets its text
|
||||
f.reply = sse(`data: {"type":"response.completed","response":{"id":"resp_r2","status":"completed","output":[]}}`)
|
||||
code, body = post(t, "/v1/responses", `{"model":"fake/m1","stream":true,
|
||||
"input":[{"type":"compaction","encrypted_content":"`+enc+`"},{"type":"message","role":"user","content":[{"type":"input_text","text":"go on"}]}]}`)
|
||||
if code != 200 {
|
||||
t.Fatalf("%d %s", code, body)
|
||||
}
|
||||
if strings.Contains(string(f.got), magpieCompaction) || !strings.Contains(string(f.got), "SUMMARY") {
|
||||
t.Errorf("relay got %s", f.got)
|
||||
}
|
||||
}
|
||||
@@ -1058,6 +1058,17 @@ func (s *Server) handle(from provider.Protocol) http.HandlerFunc {
|
||||
s.codexTitle(w, r, body, to)
|
||||
return
|
||||
}
|
||||
// Codex compacts remotely, a compaction_trigger here, on a
|
||||
// provider table named "OpenAI" (CC Switch's relay tables,
|
||||
// which magpie points here, #1301): the summary is magpie's to
|
||||
// make, as on CodexPath, and magpie's summaries go back as text
|
||||
if bytes.Contains(body, []byte(`"compaction`)) {
|
||||
var compact bool
|
||||
if body, compact = codexInput(body, true); compact {
|
||||
s.codexCompact(w, r, body)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
s.serveAgent(w, r, from, body)
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user