Files
magpie/internal/qoder/codec_test.go
T
Ethan Xu 81736b2fb2 subscription: Qoder is a subscription you sign in to and route throug… (#162)
* subscription: Qoder is a subscription you sign in to and route through — Qoder lists no public API, so magpie speaks the endpoint its own desktop client uses: api3.qoder.sh's agent_chat_generation SSE, wrapped in the COSY envelope the client signs with (an RSA-encrypted key and a signed request line), the body through the client's own base64 codec, and the account reached by Qoder's OAuth device flow (qoder.com -> a dt- device token -> a jt- job token that serves models and rotates from its refresh token). The protocol lives in internal/qoder (endpoints, codec, cosy, oauth, models, a credential store, and /api/v1/userinfo, asked with the device token at sign-in so the account is labelled by its email and name rather than a bare uid); provider/qoder.go is who is signed in, its models, and the sign-in; gateway/qoder.go turns a request into a Qoder body and reads the SSE back, folding tool calls and results the way the client does. It is offered as a single, risky subscription in the Providers page: Qoder has no public API for this, and may treat a request that isn't its own client's as third-party use — the tile says so and points at an account you can afford to lose. Tested: the codec, the COSY signing, the model list, the request body and a served round-trip against stubs, and live — a signed-in account answered a Pi request for qfmodel end to end. Not tested: the userinfo email/name path against a fresh real sign-in (the device token is short-lived), and multi-turn tool use against the real client's exact framing.

* subscription: Qoder uses the chosen model and keeps rotating tokens consistent

Qoder used qfmodel regardless of the caller's model, refreshed the same rotating token concurrently, and could return upstream failures as empty successful replies. Requests now use the selected account's enabled model key and complete model_config; unknown or disabled models are refused. Token checking, rotation and atomic persistence are serialized, each caller receives a separate credential, and a rotated pair is retained for a persistence retry if its disk write fails.

Accounts are kept in logins.json with multiple accounts, selection, secondary-account toggles, usage and removal, including the last account. The old qoder.json is migrated after a successful write. A sign-in generates one machine ID that the account keeps for subsequent requests; the OS and architecture follow the running platform. Login polling expires after 15 minutes.

Qoder's response path maps non-200 SSE envelope statuses to HTTP errors before output and terminal error events after streaming begins, including requests using magpie's search rounds. Tool-result headings stay beside their own results; normal EOF flushes pending text and emits a stop event even after tool calls. Thinking, effort and context settings follow the selected model (low/medium/high is the fallback when no effort levels are declared). Corrected the Chinese risk wording and account plan label, removed unused protocol fields and the unsupported China-client claim, and included ufec/CLIProxyAPI's MIT copyright and license. Codec and signature tests now have fixed expected answers instead of relying only on round trips or an absolute Windows capture path.

The shared relay in claude_subscription.go is unchanged. Its error-status propagation and handling of failures after partial non-streaming output are existing upstream issues outside this change; Qoder handles these failures in its own relayQoder response path.

Tested: Qoder, codec, COSY and shared stream-error regressions passed 20 consecutive runs, including eight concurrent requests refreshing once, persistence failure recovery, account migration and removal, sign-in and usage against local HTTP stubs, and error handling across Chat, Responses, Anthropic and Gemini. go vet ./..., the production build, JavaScript syntax checks and git diff --check pass. The complete Windows test run has the same 72 failing tests/subtests as HEAD, with no new failures; the existing Claude shell-client test still times out. Not tested against a live Qoder account. The race detector could not build because gcc is unavailable.

* subscription: Qoder keeps its rotating tokens, speaks tools in its own
API's way, and thinks at the effort each model declares

An aborted request could sign the account out. A refresh ran on the
request's own context, so one cancelled after Qoder had rotated the pair
but before its reply arrived dropped the new pair and left the spent one
saved: every later call failed with "qoder job token refresh: status 401"
until the account was signed in again. The failure wasn't recorded
either, so the account looked healthy in the app while every request
failed. Refreshing now runs on context.WithoutCancel with its own 20s
timeout, in provider/qoder.go and the device-token refresh in
qoder_usage.go, and a refresh Qoder refuses (401 or 403, either token)
marks the account lapsed: Qoder's job-token refresh returns a
JobTokenRefreshHTTPError with its status, as the device one did. Side
logins don't carry Lapsed out, so qoderLogins passes it on.

A refresh blocked every other subscription. Both held qoderMu and the
global loginsMu across the HTTP call, up to 20s — with a 2s refresh in
flight, Logins("workbuddy") blocked 1.9s. qoderMu still serializes read,
rotate and write-back so one pair is rotated once; loginsMu is taken only
to read (qoderLookup) and to write back (qoderPersist), and the write-back
re-reads logins.json, so a change made meanwhile is kept. migrateQoder
takes no lock when there's no legacy file to move, or listing Qoder's own
accounts would wait behind a refresh too.

Tool calls were carried as XML in the text with values magpie HTML-
escaped, so an argument reached the model as "x", the model took
to imitating the escaping, and a value it meant literally — "< b &&"
— was unescaped on the way back and corrupted. A call had no id while
its result said "Function result for call A". Qoder's endpoint serves
OpenAI's own tool calls: asked with a tools array it answers with
tool_calls carrying ids, and accepts assistant tool_calls plus a role:tool
turn named by tool_call_id. So they are sent and read that way now: no
XML contract in the prompt, no escaping on either side, and a result
paired with its call by the id Qoder sent. The XML splitter stays as a
fallback for a model that still writes a call in its text.

Effort was chosen from reasoning_efforts, a field no model lists, so
every model got the low/medium/high fallback: gfmodel, which offers only
high and max, was asked for medium and refused with "该模型始终思考,
不支持关闭思考", and xhigh was clamped to high. The levels and the
default come from thinking_config now — asked for nothing, or for a
level Qoder doesn't name, a model thinks at its own default; a model
that can't stop thinking thinks at its lowest when asked for none; one
that declares no levels is sent none. A model whose only thinking_config
is enabled now thinks, as it does in Qoder's own client. Upstream
failures carry the vendor's reason from details, which had been dropped.

Tested: go test ./... and go vet on the changed packages, and the darwin
and windows builds. New regressions cover a refresh outliving an aborted
request, a refused refresh lapsing the account, logins and a concurrent
write-back during a refresh in flight, tool turns paired by id, native
tools and streamed tool_calls, and effort defaults. Live, against a
signed-in account: two parallel calls and a follow-up call each got
their own result; a file written with "< b &&" kept them; gfmodel,
asked for nothing, answered at max; qfmodel's xhigh reached Qoder as
xhigh. Not tested: a refresh the upstream refuses for real, and models
the account isn't offered.
2026-09-29 16:40:20 +08:00

141 lines
4.4 KiB
Go

package qoder
import (
"bytes"
"crypto/rand"
"encoding/base64"
"encoding/hex"
"encoding/json"
"runtime"
"strings"
"testing"
)
// Fixed vectors use RFC 4648 base64 with the client's alphabet substituted,
// then its outer-third swap. The JSON vector is CLIProxyAPI's worker fixture.
func TestCodecKnownAnswers(t *testing.T) {
for _, tt := range []struct{ hex, encoded, wire string }{
{"", "", ""},
{"66", "D&$$", "$&$D"},
{"666f", "DOq$", "$OqD"},
{"666f6f", "DOWb", "bOWD"},
{"666f6f626172", "DOWb#OgY", "gYWb#ODO"},
{"00ff3f7f", "_vq!ef$$", "$$q!ef_v"},
{"e4b8ade69687", "QG*tQ)PZ", "PZ*tQ)QG"},
{"7b226d65737361676573223a5b5d7d", "mYKtDxj^#SJLNYByS..W", "ByS..Wj^#SJLNYmYKtDx"},
} {
t.Run(tt.hex, func(t *testing.T) {
plain, _ := hex.DecodeString(tt.hex)
if got := BodyEncode(plain); got != tt.encoded {
t.Fatalf("encode %q, want %q", got, tt.encoded)
}
if got := BodyDecode(tt.encoded); !bytes.Equal(got, plain) {
t.Fatalf("decode %x, want %x", got, plain)
}
if got := EncodeRequestBody(plain); got != tt.wire {
t.Fatalf("wire %q, want %q", got, tt.wire)
}
if got := DecodeRequestBody(tt.wire); !bytes.Equal(got, plain) {
t.Fatalf("wire decode %x, want %x", got, plain)
}
})
}
}
func TestCosySignatureKnownAnswer(t *testing.T) {
// Independently calculated MD5 of the five newline-separated fields.
const path = "/api/v2/service/pro/sse/agent_chat_generation"
if got := cosySignature("cGF5bG9hZA==", "c2VjcmV0", 1700000000, "ByS..Wj^#SJLNYmYKtDx", path); got != "da9dbed272847ea4664dd7ae8bf82575" {
t.Fatalf("signature = %q", got)
}
if got := urlPathname(APIHost + ChatPath); got != path {
t.Fatalf("signed path %q", got)
}
}
func TestCosyMachineIdentity(t *testing.T) {
u := &User{UID: "uid", Token: "jt", MachineID: "fixed-machine"}
for i := 0; i < 2; i++ {
h, err := BuildCosyHeaders(APIHost+ChatPath, u, "", 1)
if err != nil {
t.Fatal(err)
}
for _, key := range []string{"Cosy-MachineId", "Cosy-MachineToken", "Cosy-ClientIp"} {
if h[key] != "fixed-machine" {
t.Fatalf("%s = %q", key, h[key])
}
}
arch := map[string]string{"amd64": "x86_64", "386": "x86", "arm64": "aarch64"}[runtime.GOARCH]
if arch == "" {
arch = runtime.GOARCH
}
os := runtime.GOOS
if os == "windows" {
os = "win32"
}
if h["Cosy-MachineOS"] != arch+"_"+os {
t.Fatalf("OS %q", h["Cosy-MachineOS"])
}
}
}
// TestCodecRoundTrip checks the fiddliest reverse-engineered piece: a request
// body survives encode-to-wire then decode-from-wire unchanged, over every
// length so the partial final 6-bit group, the "$" padding and the
// outer-thirds swap are all exercised.
func TestCodecRoundTrip(t *testing.T) {
for n := 0; n < 200; n++ {
plain := make([]byte, n)
if _, err := rand.Read(plain); err != nil {
t.Fatal(err)
}
wire := EncodeRequestBody(plain)
if got := DecodeRequestBody(wire); !bytes.Equal(got, plain) {
t.Fatalf("round trip failed at len %d", n)
}
if len(wire)%4 != 0 {
t.Fatalf("wire length %d not a multiple of 4", len(wire))
}
}
}
// TestCosyHeadersStructure checks the request envelope is well formed: the
// Authorization is "Bearer COSY.<payload>.<md5 hex>", the payload base64
// decodes to the documented JSON, and the token is never in the clear.
func TestCosyHeadersStructure(t *testing.T) {
u := &User{UID: "uid-1", Name: "N", Email: "e@x", Token: "jt-abc", MachineID: "machine-test"}
ts := int64(1700000000)
h, err := BuildCosyHeaders(APIHost+ChatPath, u, `{"messages":[]}`, ts)
if err != nil {
t.Fatal(err)
}
auth := h["Authorization"]
if !strings.HasPrefix(auth, "Bearer COSY.") {
t.Fatalf("bad Authorization: %q", auth)
}
parts := strings.Split(strings.TrimPrefix(auth, "Bearer COSY."), ".")
if len(parts) != 2 || len(parts[1]) != 32 {
t.Fatalf("expected payload.<md5hex>, got %q", auth)
}
decoded, err := base64.StdEncoding.DecodeString(parts[0])
if err != nil {
t.Fatalf("payload not base64: %v", err)
}
var p map[string]any
if json.Unmarshal(decoded, &p) != nil {
t.Fatalf("payload not JSON: %q", decoded)
}
if p["version"] != "v1" || p["cosyVersion"] != "1.1.49" {
t.Fatalf("unexpected payload fields: %v", p)
}
if strings.Contains(string(decoded), "jt-abc") {
t.Fatal("token leaked into the payload")
}
if h["Cosy-User"] != "uid-1" {
t.Fatalf("Cosy-User wrong: %q", h["Cosy-User"])
}
if h["Cosy-Date"] != "1700000000" {
t.Fatalf("Cosy-Date wrong: %q", h["Cosy-Date"])
}
}