Skip to content

fix(copilot_auth): route Responses-only GPT models through /responses - #58

Open
snair20-1984 wants to merge 1 commit into
mpfaffenberger:mainfrom
snair20-1984:fix/copilot-responses-endpoint
Open

fix(copilot_auth): route Responses-only GPT models through /responses#58
snair20-1984 wants to merge 1 commit into
mpfaffenberger:mainfrom
snair20-1984:fix/copilot-responses-endpoint

Conversation

@snair20-1984

@snair20-1984 snair20-1984 commented Sep 4, 2026

Copy link
Copy Markdown

Why

Every Copilot model was built as an OpenAIChatModel, so every request went to /chat/completions. Copilot's newer GPT models (gpt-5.5, gpt-5.6-luna/sol/terra, gpt-5.4-mini, gpt-5.3-codex, plus grok and mai-code) are Responses-only and reject that with:

status_code: 400, model_name: gpt-5.6-terra, body: {'message': 'model "gpt-5.6-terra" is not accessible via the /chat/completions endpoint', 'code': 'unsupported_api_for_model'}

The Copilot /models catalogue already says which endpoints each model accepts (supported_endpoints), but fetch_copilot_models threw that away and add_models_to_config persisted nothing the model factory could key off.

Routing those models through OpenAIResponsesModel clears the 400 but exposes a second problem: Copilot's /responses proxy re-encrypts every identifier on every SSE event. The id in output_item.added, the item_id on each function_call_arguments.delta / output_text.delta, and the item.id in output_item.done are all different opaque strings for the same item. pydantic-ai keys streamed parts by that id, so tool calls came through with empty arguments (read_file ... file_path Field required, retried to death) and text was split into one part per delta.

What

  • utils.pyfetch_copilot_models now returns {"id", "api", "context_length"} entries derived from supported_endpoints and capabilities.limits.max_context_window_tokens. add_models_to_config persists a new copilot_api: "chat" | "responses" field and uses the catalogue context length (terra was registered at 128k; the API says 400k). Chat stays the default for dual-endpoint models (gpt-5.4, gpt-5-mini) and for Claude/Gemini, so the reasoning_opaque path is untouched. Bare-string ids and the DEFAULT_COPILOT_MODELS fallback still work.
  • register_callbacks.py_create_copilot_model picks OpenAIResponsesModel when copilot_api == "responses". Entries written before the field existed fall back to a name heuristic (gpt-5* / codex), so existing copilot_models.json files are fixed without a re-login. Provider, headers, _CopilotAuth and base_url handling are unchanged; the chat branch still returns the same class as before, so this rebases cleanly onto fix(copilot_auth): tolerate missing object/index in chat completions #23's CopilotChatModel.
  • responses_stream.py (new) — httpx byte-stream shim, same shape as the reasoning_client.py interceptor, that rewrites id / item_id per output_index so every event for an item shares the first id seen. Installed only on the Responses path; /chat/completions, non-streaming JSON and error responses pass through send untouched.

Tests

  • tests/test_copilot_auth_utils.py (new): preferred_api_for_endpoints truth table, catalogue parsing with the real response shape, registration writes copilot_api and context length, normalised entries round-trip.
  • tests/test_copilot_responses_stream.py (new): id normalisation across the exact event sequence captured from gpt-5.6-terra, chunk-boundary buffering, non-JSON/[DONE] passthrough, MockTransport coverage that only streaming /responses bodies are wrapped.
  • tests/test_copilot_auth_model.py: Responses vs chat class selection, heuristic fallback, shim installed only on the Responses path, Claude keeps its reasoning profile.

Full suite: 2181 passed, 2 skipped. ruff check / ruff format --check clean.

Verified live against an enterprise Copilot host: copilot-gpt-5.6-terra answers and completes a multi-tool round trip (list_filesread_file → answer), copilot-claude-sonnet-5 (chat + reasoning_opaque) and copilot-gpt-5.4 (dual-endpoint, chat) still work, and a legacy entry without copilot_api selects the Responses model via the heuristic.

Follow-ups (not in this PR)

  • code_puppy's model_factory still strips reasoning_effort for all Copilot GPT models because /chat/completions rejected it; the /responses catalogue entries advertise reasoning_effort support, so that can now be enabled behind copilot_api == "responses".
  • The plugin still builds a legacy httpx.AsyncClient, which triggers pydantic-ai's httpx2 deprecation warning; separate change.

Fixes #57

Copilot's newer GPT models (gpt-5.5, gpt-5.6-*, gpt-5.4-mini, codex, grok,
mai-code) only accept /responses and 400 with unsupported_api_for_model on
/chat/completions, which is all the plugin ever sent.

- Keep supported_endpoints and max_context_window_tokens from the /models
  catalogue and persist a copilot_api ("chat" | "responses") field per
  registered model; chat stays the default for dual-endpoint models.
- Build OpenAIResponsesModel when copilot_api == "responses", with a
  gpt-5* / codex name heuristic for entries registered before the field.
- Add responses_stream.py: Copilot re-encrypts every item id on every SSE
  event and pydantic-ai keys streamed parts by that id, so tool-call
  arguments arrived empty and text split per delta. The shim rewrites
  id / item_id per output_index so each item keeps one stable id.

Fixes mpfaffenberger#57

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

copilot_auth: GPT-5.5/5.6/Codex models fail with 400 unsupported_api_for_model (Responses-only models sent to /chat/completions)

1 participant