Skip to content

feat(zai): add zai-anthropic binding — GLM models via the Claude Agent SDK - #86

Open
pofallon wants to merge 2 commits into
fix/scope-anthropic-credentials-to-anthropic-endpointfrom
feat/zai-anthropic
Open

feat(zai): add zai-anthropic binding — GLM models via the Claude Agent SDK#86
pofallon wants to merge 2 commits into
fix/scope-anthropic-credentials-to-anthropic-endpointfrom
feat/zai-anthropic

Conversation

@pofallon

Copy link
Copy Markdown
Contributor

Stacked on #84 — base branch is fix/scope-anthropic-credentials-to-anthropic-endpoint, not main. Both touch claude_code.py. Merge #84 first and this retargets to main cleanly. Depends conceptually too: this is the subprocess-boundary counterpart to #84's HTTP-boundary fix.

Z.AI exposes an endpoint that speaks Anthropic's Messages wire format, which is what lets the claude CLI drive it at all. This adapter reuses ClaudeCodeRuntime's entire harness — subprocess lifecycle, sessions, streaming, tools, hooks — and changes only what makes it a different binding.

Why its own PROVIDER_ID

Per the binding rule (#85): supports() is a ClassVar manifest, so one ID carries exactly one honest capability surface. claude declares 22 features against Anthropic's endpoint; several are unverified against Z.AI's. Sharing the ID would make supports() lie about one of the two.

zai-anthropic rather than a bare zai because Z.AI also fronts an OpenAI-compatible surface — that belongs to a future zai-openai sibling on OpenAICompatibleRuntime. Same reasoning that kept the Kimi Agent SDK adapter from claiming moonshot.

Pointing ClaudeCodeRuntime at Z.AI via ANTHROPIC_BASE_URL still works as an escape hatch. An escape hatch is not a supported binding.

Credentials

ZAI_API_KEYANTHROPIC_AUTH_TOKEN in the subprocess env, with no fallback to any Anthropic credential.

_subprocess_env() also shadows inherited ANTHROPIC_API_KEY / ANTHROPIC_AUTH_TOKEN / CLAUDE_CODE_OAUTH_TOKEN with empty strings. The Agent SDK merges ClaudeAgentOptions.env over os.environ and offers no way to unset a key, so shadowing is the only available mechanism. Without it, a developer with CLAUDE_CODE_OAUTH_TOKEN in a shell profile would have the CLI carry their Anthropic subscription token to Z.AI — the same leak class as #84, one layer down.

Feature surface: deliberately understated

Group Treatment
CLI-side (streaming, resume, cancel, tools, MCP, hooks, permission, budget, slash commands) Retained — protocol-independent
Endpoint-dependent (thinking, vision, file input, count_tokens, rate-limit telemetry, request metadata) False pending live verification

A supports() that overstates produces a runtime failure the consumer was told couldn't happen; one that understates just routes them elsewhere.

STRUCTURED_OUTPUT_JSON_SCHEMA stays True despite being equally unverified. The conformance suite makes it the floor (test_supports_structured_output_json_schema_is_true), so declaring it False would produce a non-conforming adapter rather than a cautious one. If a live probe shows Z.AI doesn't honour --json-schema, that's a blocker on the adapter, not a bit to flip. examples/probe_zai.py checks it first and says so explicitly.

Shared-code changes

This is the first adapter to decline features its harness implements, which exposed three gaps:

  • ClaudeCodeRuntime._subprocess_env() extracted as an overridable hook. The env dict was built inline in the session and hardcoded ANTHROPIC_API_KEY.
  • _check_thinking_supported() added to sessions.py, called before connect. The thinking= kwarg had no capability gate at all — ClaudeCodeRuntime never noticed because it declares both reasoning flags True. Three conformance contracts caught this.
  • VISION_INPUT / FILE_INPUT now read from the capability flags in _split_prompt_parts calls instead of hardcoded True.

Also registers zai-anthropic in the conformance provider-options map — it reuses ClaudeOptions, since a distinct binding doesn't imply a distinct options namespace.

Overrides

  • list_models() → static catalog. No /v1/models to query; per-token pricing is None rather than invented, since the coding plan bills flat-fee.
  • count_tokens()UnsupportedFeatureError. Inheriting the parent would POST to /v1/messages/count_tokens, a route Z.AI doesn't serve.

Tests

make ci green: 1080 → 1113 passed, 40 skipped.

  • tests/test_zai.py — 33 tests covering identity, auth resolution, the credential-shadowing regression (parametrized over all three leaky vars), capability surface, and both overridden endpoints.
  • tests/test_zai_conformance.py — the full 38-contract suite. Running it here is the point: most contracts are "behaviour agrees with the capability flag", so they assert the opposite branch than they do for the parent.
  • tests/test_discovery.py — updated. Installing [claude] now surfaces two providers, which is the binding rule made concrete: PROVIDER_ID is not REQUIRES_PACKAGE.

Not verified

I have no Z.AI key, so every live behaviour is unverified — hence the conservative flags and the probe script. The model catalog (glm-4.6, glm-4.5-air) and their context windows come from outside knowledge, not from the code; worth a check against Z.AI's current docs before release.

🤖 Generated with Claude Code

https://claude.ai/code/session_01UBTr6Q6kTGUMQiwwBHcdMj

pofallon and others added 2 commits August 23, 2026 13:20
…t SDK

Z.AI exposes an endpoint speaking Anthropic's Messages wire format, which
is what lets the `claude` CLI drive it. This adapter reuses
ClaudeCodeRuntime's entire harness and changes only the three things that
make it a different binding: base URL, auth variable, feature surface.

Its own PROVIDER_ID rather than a config flag on `claude`, per the binding
rule: `supports()` is a ClassVar manifest, so one ID carries exactly one
honest capability surface. `claude` declares 22 features against
Anthropic's endpoint; several are unverified against Z.AI's. The ID is
`zai-anthropic` rather than a bare `zai` because Z.AI also fronts an
OpenAI-compatible surface, which belongs to a future `zai-openai` sibling.

Credential handling:

- `ZAI_API_KEY` → `ANTHROPIC_AUTH_TOKEN` in the subprocess env. No
  fallback to any Anthropic credential — those authenticate a different
  account at a different vendor.
- `_subprocess_env()` shadows inherited ANTHROPIC_API_KEY /
  ANTHROPIC_AUTH_TOKEN / CLAUDE_CODE_OAUTH_TOKEN with empty strings. The
  Agent SDK merges ClaudeAgentOptions.env over os.environ and cannot
  unset a key, so shadowing is the only mechanism available. This is the
  subprocess-boundary counterpart to the HTTP-boundary fix in #84.

Feature surface is deliberately understated. CLI-side features
(streaming, resume, cancel, tools, hooks, permission, budget, slash
commands) are protocol-independent and retained. Endpoint-dependent ones
(thinking, vision, file input, count_tokens, rate-limit telemetry,
request metadata) report False pending live verification, because a
supports() that overstates produces a runtime failure the consumer was
told couldn't happen. STRUCTURED_OUTPUT_JSON_SCHEMA stays True despite
being equally unverified — the conformance suite makes it the floor, so
declaring it False would yield a non-conforming adapter rather than a
cautious one. examples/probe_zai.py verifies each flag against a real key.

Supporting changes to shared code, all driven by this being the first
adapter to decline features its harness implements:

- Extract `ClaudeCodeRuntime._subprocess_env()` as an overridable hook.
  Previously the env dict was built inline in the session and hardcoded
  ANTHROPIC_API_KEY.
- Add `_check_thinking_supported()` to sessions.py and call it before
  connect. The thinking= kwarg had no capability gate; ClaudeCodeRuntime
  never noticed because it declares both reasoning flags True.
- Read VISION_INPUT / FILE_INPUT from the capability flags in
  `_split_prompt_parts` calls rather than hardcoding True.
- Register `zai-anthropic` in the conformance suite's provider-options
  map. It reuses ClaudeOptions — a distinct binding does not imply a
  distinct options namespace.

Overrides that would otherwise misbehave: `list_models()` returns a
static catalog (no /v1/models to query) and `count_tokens()` raises
UnsupportedFeatureError (no such route on Z.AI).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UBTr6Q6kTGUMQiwwBHcdMj
…nesses

The adapter shipped without registering in the credential maps the
existing test tooling reads, so it was invisible to all of it:

- `testing/integration.py::_PROVIDER_AUTH` had no entry, and
  `_has_credentials` returns False for any unlisted provider — every
  integration test would have skipped silently even with a valid key.
- `examples/smoke_providers.py` printed "no known credential mapping"
  rather than exercising the adapter.
- `.env.example` documented seven of eight bindings.

Adds the missing entries plus `tests/test_zai_integration.py`, which
imports a deliberately narrower contract set than the Claude suite:
thinking is declined pending verification (the unit conformance suite
already covers the decline), and `list_models()` is a static catalog with
no network call, so a green result there would prove nothing about the
endpoint. Both become worth importing if examples/probe_zai.py promotes
the corresponding flags.

The fixture skips on a missing `claude` CLI as well as a missing key —
this binding needs both.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UBTr6Q6kTGUMQiwwBHcdMj
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.

1 participant