-
Confirm the exact id Copilot exposes (Step 0) AND the id Claude Code
sends. For Claude models, verify CopilotModelRegistry.Normalize maps the
client id → the canonical catalog id (usually identity for dotted ids; add a
Normalize test if a date suffix or digit-pair merge is involved). For a
Claude model, consult the claude-api skill for the authoritative id string.
-
Probe the wire contract. Add the new id to ModelProfileProbe.AllModels
(drives the thinking × effort matrix) plus targeted probes mirroring the
nearest known model: combined effort+adaptive-thinking (the shape Claude Code
really sends), mid-conv-role:"system" placement matrix, and a >200k-token
1M-context probe. Run them live:
dotnet test tests/CopilotBridge.Playground --filter "FullyQualifiedName~<YourProbe>" --logger "console;verbosity=detailed"
Read the → HTTP N lines: 200 = accepted, 400 = rejected. Do not skip
a probe because the model "looks like" a known family — sonnet-5's contract
matched opus-4.8, not its own sonnet-4.6 predecessor.
-
Fidelity check — re-confirm every REWRITE-causing finding on real captured
client bytes. The probes above are hand-written minimal requests (~150 bytes,
no system blocks, no betas, non-streaming). A real Claude Code request is ~20×
larger and carries 3 system blocks, ~8 anthropic-beta tokens, tool
definitions, cache_control, and stream:true. So a minimal-request result
answers "does Copilot accept this shape in isolation" — not "does this
rule still hold on what the client actually sends."
That gap only matters for findings the bridge acts on. Sort your results:
| Finding | Bridge does | Fidelity check |
|---|
| Model accepts X | nothing (passthrough) | not needed — a wrong "accept" surfaces as Copilot's own visible 400 |
| Model rejects X → profile makes the bridge strip / clamp / coerce | rewrites the request | REQUIRED |
The asymmetry is the point: a false accept fails loudly upstream, but a
false reject makes the bridge silently downgrade a request the backend would
have taken — the user loses capability with no error anywhere. That is
unobservable in production, so it must be ruled out before shipping.
Replay a real captured body, mutating only the axis under test:
var capturedModel = captured["body"]!["model"]?.GetValue<string>()
?? throw new InvalidOperationException("capture has no body.model");
Assert.Equal(modelUnderTest, capturedModel);
var raw = captured["body"]!;
var body = raw is JsonValue v
? JsonNode.Parse(v.GetValue<string>())!.AsObject()
: raw.DeepClone().AsObject();
var effortParent = isResponsesCapture ? "reasoning" : "output_config";
(body[effortParent] ??= new JsonObject()).AsObject()["effort"] = effort;
var (status, resp) = isResponsesCapture
? await client.TryPostResponsesAsync(body.ToJsonString())
: await client.TryPostMessagesAsync(
body.ToJsonString(),
anthropicBeta: captured["headers"]?["anthropic-beta"]?.GetValue<string>());
Effort is only the worked example. Whatever axis you probed, locate it in
the capture's own schema before mutating — the two backends diverge on more
than this one field (thinking shape, tool arrays, beta headers). A mutation
that lands on a field the target backend ignores makes the check pass while
testing nothing, which is worse than skipping it.
Same verdict on both = the rule is model-level and your profile's scope is
right. Divergence = the minimal probe misled you — a beta, a system block,
or streaming changed the answer, and the profile would encode a rule that does
not apply to real traffic. Worked example: opus-5's thinking:disabled ×
xhigh/max rejection, confirmed byte-identical (2756 B, 3 system blocks, 8
betas) before the clamp shipped.
Prerequisite for a brand-new model: you must create the capture first, and
step 7 does NOT create it for you. Only Kind=ClientBehavior cases produce
tests/behavior-runs/ (that path is written by ServeProcess); step 7's Codex
load-task smoke is Kind=ApiContract and runs on BridgeFixture, so it writes
nothing there. The behavior cases also target fixed latest-model constants
(ClientBehaviorSupport.LatestClaude / LatestGpt), so a model that is not yet
the constant produces no capture of its own. Before this step can run on a new
id, either retarget the constant (only once the catalog knows the id — see
real-client-verify's models.md) or add a candidate-targeted behavior case.
Then run it to generate the capture.
A new Codex id needs routing before it can produce a capture at all.
Pointing a behavior case at the id is not enough: until it is in
ResponsesModelIds, CopilotModelRegistry sends it to the unimplemented
chat-completions branch (CopilotModelRegistry.cs:51-64), so no /responses
capture is written — and if no catalog entry fuzzy-matches, ModelRouterStage
fails the request outright before any upstream call. So for a Codex candidate
the order is: allowlist the id (+ a provisional CodexModelProfile if nothing
close matches) → run the behavior case → come back and do this fidelity check
→ only then finalize the profile in step 4. The provisional entry is scaffolding
to obtain evidence, not a probed fact; it must be replaced by probed values
before you ship.
-
Write the ModelProfile in ModelProfileCatalog.BuildDefault() (or a
CodexModelProfile in CodexModelProfileCatalog) with every field grounded
in a probe result, and a code comment citing the probe method name for each
non-obvious field. Fields: AcceptedEfforts, EffortOnUnsupported,
Thinking (AdaptiveOnly / AdaptiveOrDisabled / EnabledOnly / All),
MaxThinkingBudget, AcceptsMidConversationSystem, StripBetas.
Pick the thinking policy from the probe's rejections, not from the family.
If only enabled was rejected, the model takes disabled too and the policy
is AdaptiveOrDisabled — choosing AdaptiveOnly there silently coerces a
user's explicit thinking:disabled back to adaptive (re-enabling and billing
reasoning they turned off) and makes any disabled-thinking constraint
unreachable. That is exactly opus-5's case.
-
Routing check. Vendor dispatch is prefix-only (claude-* → /v1/messages,
gpt/mai-code in ResponsesModelIds → /responses), so a claude id needs no
registry change. A new Codex id must be added to ResponsesModelIds in
CopilotModelRegistry or it falls through to the OpenAI-chat branch. Add a
Routing.Locations entry in appsettings.json only if the model needs a
deliberate remap (e.g. a context-window alias like gpt-5.5-1m).
-
Tests (from the contract, not the code). Add from-contract unit tests
asserting the profile's behavior (see ProfileAdjusterTests,
CodexRoutingAndCatalogTests) and mutation-check each new assertion:
break the product value, confirm the test goes red. A new test that passes on
the first run guards nothing.
A rewrite rule needs a BACKEND-fact guard too, not just a behavior test. A
unit test pinning "the bridge clamps" stays green forever if Copilot drops the
constraint — and the bridge keeps silently downgrading. So sweep the finding
into the contract sweep, which snapshots it (B2 catches the backend ADDING the
rule elsewhere) and compares it against the catalog (B3 catches it being
DROPPED).
Know what the sweeps actually cover today — the guard is not automatic.
| Sweep | B2 snapshot | B3 catalog-vs-live |
|---|
AnthropicContractSweep | effort (accepted+rejected), thinking (accepted+rejected), mid-conv-system, effort×thinking-disabled | same four; AcceptedEfforts and EffortsRejectedWhenThinkingDisabled are exact-set both ways, Thinking is catalog→live only |
ResponsesContractSweep | effort (accepted+rejected), fields_rejected, tools_rejected, plus a backend-wide sse_event_types | none — the Codex catalog/coercions post-date it |
Read that B2 column before adding a probe: if the axis is already snapshotted,
the work is adding the B3 comparison, not re-probing the field.
Not covered by any B3: StripBetas, MaxThinkingBudget, every Codex
(CodexModelProfile) field, and the live→catalog direction of Thinking. If
your rewrite rule lands on one of those, extend the sweep as part of this
step — don't assume writing the profile field gave you a guard. Adding the
axis is the same shape as the 2026-07 effort_rejected_when_thinking_disabled
addition: probe it per model in the sweep loop, add it to the facts object, and
assert it in AssertCatalogMatchesLive. Mutation-check the new assertion the
same way (break the catalog value, watch B3 redden).
-
Load-task smoke — MANDATORY for a Codex (gpt-* / mai-code-*) model. A
liveness/effort probe and a plain one-word turn do not exercise a real Codex
client tool loop — multi-call function_call/function_call_output round-trips
and reasoning echoes only appear when the real codex.exe runs an actual
multi-step task. That loop (plus model routing) is what this smoke guards.
So for every added Codex id, run the real-client load-task smoke against that
id. The repo's default shell is PowerShell (set the env var inline, then run):
$env:CODEX_SMOKE_MODEL="<new-id>"; dotnet test tests/CopilotBridge.Playground `
--filter "FullyQualifiedName~CodexLoadTaskSmoke" --logger "console;verbosity=detailed"
(bash/CI equivalent: CODEX_SMOKE_MODEL=<new-id> dotnet test … --filter "FullyQualifiedName~CodexLoadTaskSmoke".)
It must exit 0 with the canary in stdout AND the bridge audit must show the
model on the wire plus a real function_call/function_call_output round-trip
(the test asserts all of these, so a prompt-echo can't pass it). If it 400s on
an unmodeled inbound shape (Polymorphism_UnrecognizedTypeDiscriminator, a new
input[]/tool type), that shape is a NEW change: probe whether Copilot
accepts it natively (ResponsesProbe), then model + carry it — the
add-codex-additional-tools-item change under openspec/changes/ (or
openspec/changes/archive/ if later archived) is the worked example.
Caveat: this smoke exercises only what the codex exec CLI emits, which is a
subset of the full client wire — notably it does NOT send the desktop app's
input[0] additional_tools preamble. Shapes the CLI doesn't emit need a
direct HTTP-edge replay of a real capture through /codex/responses (see
CodexAdditionalToolsHeadlessTests), so add one whenever you model a new
desktop-only inbound shape. For a Claude model the claude.exe headless smoke
(CcOnGpt5*HeadlessTests/HeadlessSmokeTests) is the equivalent load task.
-
Docs + memory. Update docs/pipeline-design.md (§7 catalog),
docs/context-window.md, and the model-count references; add a dated entry to
docs/design.md. Update the user-account memory if the available set changed.