Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions artifacts/issue-3670-anthropic-cache-eval.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@
"source": {
"url": "https://platform.claude.com/docs/en/build-with-claude/prompt-caching",
"retrievedAt": "2026-07-18",
"providerSourceBlobOid": "ca40efcc01da0ebbc78f018563a8a4474d77ba36",
"providerSourceSha256": "b3173948f5982c97b41790f53fbc396d514e5ce36cfa316453447527aa0236e8",
"providerSourceBlobOid": "bc15958b9b80add7af990abd0e82d0450640a931",
"providerSourceSha256": "3d926409bf0a7b4ec1d7222a4c402f18b3ee5c01a5f01c6d84d8894bda16e963",
"inputFixtureSha256": "b1ca8d3183ca168140774f848ed414252178f7c5788d61243069862ae59c129b"
},
"derivationCommands": [
Expand Down
115 changes: 115 additions & 0 deletions artifacts/issue-3900-live-cpa-probe.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
// Live probe for issue #3900, via the CPA proxy configured in
// ~/.gjc/agent/models.yml (fallback credentials — no direct Anthropic key on
// this machine).
//
// Step 1: run a real tool-use turn with thinking enabled and capture the
// genuinely signed thinking block.
// Step 2: tamper the thinking text (signature now mismatches), append the
// tool_result, and continue the turn. Anthropic rejects exactly this shape
// with the "thinking ... cannot be modified" 400; behind CPA it can arrive
// as a statusless SSE error event. Expected: the provider classifies the
// rejection, runs the thinking-replay repair, and the turn recovers.
import * as os from "node:os";
import * as path from "node:path";
import { Effort } from "../packages/ai/src/model-thinking";
import { streamAnthropic } from "../packages/ai/src/providers/anthropic";
import type { Context, Model, ToolResultMessage, UserMessage } from "../packages/ai/src/types";

const modelsYml = await Bun.file(path.join(os.homedir(), ".gjc", "agent", "models.yml")).text();
const anthropicBlock = /anthropic:\n(?:\s+.+\n?)+?(?=\n\S|$)/.exec(modelsYml)?.[0] ?? "";
const baseUrl = /baseUrl:\s*(\S+)/.exec(anthropicBlock)?.[1];
const apiKey = /apiKey:\s*"?([^"\n]+)"?/.exec(anthropicBlock)?.[1];
if (!baseUrl || !apiKey) throw new Error("models.yml fallback credentials not found");

const modelId = process.argv[2] ?? "claude-opus-5";
const model: Model<"anthropic-messages"> = {
api: "anthropic-messages",
provider: "anthropic",
id: modelId,
name: modelId,
baseUrl,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
maxTokens: 32_000,
contextWindow: 200_000,
reasoning: true,
thinking: { mode: "anthropic-adaptive", minLevel: Effort.Minimal, maxLevel: Effort.XHigh },
};

const tools: Context["tools"] = [
{
name: "ping",
description: "returns pong",
parameters: { type: "object", properties: {}, required: [] } as never,
},
];
const user: UserMessage = {
role: "user",
content: "Think briefly about why you must call the ping tool, then call it exactly once.",
timestamp: Date.now(),
};

// Step 1: obtain a genuinely signed thinking + tool_use turn.
const firstTurn = await streamAnthropic(
model,
{ systemPrompt: ["Use the ping tool when asked."], tools, messages: [user] },
{ apiKey, isOAuth: false, thinkingEnabled: true, effort: "xhigh", maxTokens: 4_096 },
).result();
const thinkingBlock = firstTurn.content.find(b => b.type === "thinking");
const toolCall = firstTurn.content.find(b => b.type === "toolCall");
if (firstTurn.stopReason !== "toolUse" || !toolCall) {
console.log(JSON.stringify({ step: 1, stopReason: firstTurn.stopReason, error: firstTurn.errorMessage }));
throw new Error("step 1 did not produce a tool_use turn");
}
const signature = thinkingBlock?.type === "thinking" ? thinkingBlock.thinkingSignature : undefined;
console.log(
JSON.stringify({
step: 1,
stopReason: firstTurn.stopReason,
hasSignedThinking: !!signature,
signaturePrefix: signature?.slice(0, 12),
}),
);

// Step 2: tamper the signed thinking text and continue with the tool result.
if (thinkingBlock?.type === "thinking") {
thinkingBlock.thinking = `${thinkingBlock.thinking} [TAMPERED issue #3900]`;
}
const toolResult: ToolResultMessage = {
role: "toolResult",
toolCallId: toolCall.id,
toolName: toolCall.name,
content: [{ type: "text", text: "pong" }],
isError: false,
timestamp: Date.now() + 1,
};
const payloads: string[] = [];
const secondTurn = await streamAnthropic(
model,
{ systemPrompt: ["Use the ping tool when asked."], tools, messages: [user, firstTurn, toolResult] },
{
apiKey,
isOAuth: false,
thinkingEnabled: true,
effort: "xhigh",
maxTokens: 4_096,
onPayload: payload => {
payloads.push(JSON.stringify(payload));
return undefined;
},
},
).result();

const report = {
step: 2,
baseUrl,
model: modelId,
requests: payloads.length,
firstRequestHadTamperedThinking: payloads[0]?.includes("TAMPERED issue #3900") ?? false,
lastRequestHadTamperedThinking: payloads.at(-1)?.includes("TAMPERED issue #3900") ?? false,
stopReason: secondTurn.stopReason,
errorMessage: secondTurn.errorMessage,
text: secondTurn.content.filter(b => b.type === "text").map(b => (b as { text: string }).text),
};
console.log(JSON.stringify(report, null, 2));
if (secondTurn.stopReason !== "stop") process.exit(1);
98 changes: 98 additions & 0 deletions artifacts/issue-3900-sse-proxy-sim.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
// Issue #3900 wire-level simulation: a local proxy that behaves like
// CLIProxyAPI — it answers HTTP 200 and delivers Anthropic's 400 body as an
// in-stream SSE `error` event (the exact captured rejection). The second
// request succeeds. Runs the real streamAnthropic + Anthropic SDK transport,
// so it exercises iterateAnthropicEvents' statusless error throw and the
// thinking-replay repair end-to-end without any credentials.
import { streamAnthropic } from "../packages/ai/src/providers/anthropic";
import type { AssistantMessage, Context, Model, UserMessage } from "../packages/ai/src/types";

// `masked` reproduces the live 2026-08-06 CPA capture: the proxy replaces the
// upstream body entirely, so the client only sees a generic `api_error`.
const capturedError =
process.argv[2] === "masked"
? '{"type":"error","error":{"type":"api_error","message":"An error occurred while processing the request."}}'
: '{"type":"error","error":{"type":"invalid_request_error","message":"messages.5.content.1: `thinking` or `redacted_thinking` blocks in the latest assistant message cannot be modified. These blocks must remain as they were in the original response."}}';

const successFrames = [
['message_start', '{"type":"message_start","message":{"id":"msg_sim","usage":{"input_tokens":1,"output_tokens":0,"cache_read_input_tokens":0,"cache_creation_input_tokens":0}}}'],
['content_block_start', '{"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}'],
['content_block_delta', '{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"recovered"}}'],
['content_block_stop', '{"type":"content_block_stop","index":0}'],
['message_delta', '{"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"input_tokens":1,"output_tokens":1,"cache_read_input_tokens":0,"cache_creation_input_tokens":0}}'],
['message_stop', '{"type":"message_stop"}'],
] as const;

const requestBodies: string[] = [];
const server = Bun.serve({
port: 0,
async fetch(req) {
if (!new URL(req.url).pathname.endsWith("/v1/messages")) return new Response("not found", { status: 404 });
requestBodies.push(await req.text());
const frames =
requestBodies.length === 1
? [`event: error\ndata: ${capturedError}\n\n`]
: successFrames.map(([event, data]) => `event: ${event}\ndata: ${data}\n\n`);
return new Response(frames.join(""), {
status: 200,
headers: { "content-type": "text/event-stream", "request-id": `req_sim_${requestBodies.length}` },
});
},
});

const model: Model<"anthropic-messages"> = {
api: "anthropic-messages",
provider: "anthropic",
id: "claude-opus-5",
name: "claude-opus-5",
baseUrl: `http://127.0.0.1:${server.port}`,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
maxTokens: 8_192,
contextWindow: 200_000,
reasoning: true,
};
const user: UserMessage = { role: "user", content: "first", timestamp: Date.now() };
const assistant: AssistantMessage = {
role: "assistant",
content: [
{ type: "thinking", thinking: "signed replay thinking", thinkingSignature: "sig_issue_3900" },
{ type: "text", text: "history answer" },
],
api: "anthropic-messages",
provider: "anthropic",
model: "claude-opus-5",
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "stop",
timestamp: Date.now(),
};
const context: Context = {
messages: [user, assistant, { ...user, content: "next prompt", timestamp: Date.now() + 1 }],
};

const result = await streamAnthropic(model, context, {
apiKey: "sk-ant-api-sim",
isOAuth: false,
thinkingEnabled: true,
}).result();
server.stop(true);

const report = {
requests: requestBodies.length,
firstRequestHadSignedThinking: requestBodies[0]?.includes("sig_issue_3900") ?? false,
repairedRequestDroppedThinking: requestBodies[1] !== undefined && !requestBodies[1].includes("sig_issue_3900"),
stopReason: result.stopReason,
errorMessage: result.errorMessage,
text: result.content.filter(b => b.type === "text").map(b => (b as { text: string }).text),
};
console.log(JSON.stringify(report, null, 2));
if (result.stopReason !== "stop" || requestBodies.length !== 2 || !report.repairedRequestDroppedThinking) {
process.exit(1);
}
22 changes: 22 additions & 0 deletions docs/external-control-readiness.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,28 @@ Air-created Git worktrees are supported because each ACP request's absolute `cwd
Session title and update metadata are advisory state for the active ACP process. Text, thought, tool-call, and tool-result history is replayed on load, but historical binary image bytes are not replayed.

See [Environment Variables](./environment-variables.md#11-acp-permission-handling) for supported values and precedence.
## Paseo custom agent

[Paseo](https://github.com/getpaseo/paseo) registers GJC as a generic ACP provider through its custom provider configuration. Add this entry to `$PASEO_HOME/config.json` (default `~/.paseo/config.json`); Paseo then lists **Gajae Code** in its provider picker with GJC's model catalog and Default/Plan modes:

```json
{
"version": 1,
"agents": {
"providers": {
"gjc": {
"extends": "acp",
"label": "Gajae Code",
"command": ["gjc", "acp"]
}
}
}
}
```

GJC's ACP session configuration carries the spec-defined `category` on the Mode, Model, and Thinking select options (`mode`, `model`, `thought_level`), which lets ACP clients such as Paseo discover models and thinking levels without provider-specific metadata. The model catalog is filtered to providers with usable stored credentials (`providers.list/active`), falling back to the full catalog on session hosts that do not expose that query.

Sessions launched through an ACP client (e.g. `paseo run --provider gjc/...`) are broker-managed and appear in ACP `session/list`, so Paseo's import flow can attach them. Interactive `gjc` sessions host their own SDK endpoint and are not broker-registered, so they are not listed by ACP clients; use the GJC SDK/notifications surface to control those sessions.

## ACP conformance and Air release gates

Expand Down
31 changes: 28 additions & 3 deletions docs/models.md
Original file line number Diff line number Diff line change
Expand Up @@ -233,7 +233,7 @@ Built-in profiles are grouped by provider mix and tier:
- `opencodego` — single OpenCode Go preset (Kimi default, DeepSeek executor/architect, Qwen planner, MiMo critic)
- `claude-opus` — Anthropic OAuth preset centered on `claude-opus-5`
- Single-provider tiers: `glm-{eco,medium,pro}`, `kimi-coding-plan-{eco,medium,pro}`, `mimo-{eco,medium,pro}`, `grok-{eco,medium,pro}`, `cursor-{eco,medium,pro}`, `minimax-{eco,medium,pro}`
- Alibaba Token Plan: `alibaba-token-plan-balanced` preserves the established Qwen/DeepSeek V4 Pro/GLM mix; `alibaba-token-plan-pro` raises execution and independent criticism with DeepSeek V4 Flash 0731 max and GLM xhigh; `alibaba-token-plan-qwenmaxxing` stays Qwen-only; `alibaba-token-plan-qwen-deepseek` keeps Qwen 3.8 Max (`qwen-3.8-max`) on the expensive default (high)/architect (xhigh)/critic (xhigh) roles and spends DeepSeek V4 Flash 0731 on the cheap planner (max) and executor (high) roles; `alibaba-token-plan-glm-deepseek` does the same with GLM 5.2 (`glm-5.2`) as the expensive model
- Alibaba Token Plan: `alibaba-token-plan-balanced` preserves the established Qwen/DeepSeek V4 Pro/GLM mix; `alibaba-token-plan-pro` raises execution and independent criticism with DeepSeek V4 Flash 0731 max and GLM xhigh; `alibaba-token-plan-qwenmaxxing` stays Qwen-only; `alibaba-token-plan-qwen-deepseek` keeps Qwen 3.8 Max (`qwen3.8-max`) on the expensive default (high)/architect (xhigh)/critic (xhigh) roles and spends DeepSeek V4 Flash 0731 on the cheap planner (max) and executor (high) roles; `alibaba-token-plan-glm-deepseek` does the same with GLM 5.2 (`glm-5.2`) as the expensive model
- Combos: `opus-codex`, `codex-opencodego`, and `fable-opus-codex`

The `eco`, `medium`, and `pro` Codex profile mappings are current product judgments: Eco assigns Terra low/Luna low/Luna high/Terra xhigh/Terra high to default/executor/planner/critic/architect; Medium assigns Sol low/Terra low/Terra high/Sol xhigh/Sol high; Pro assigns Sol medium/Terra medium/Sol high/Sol max/Sol xhigh; and LunaMaxxing assigns Luna medium/Luna xhigh/Luna max/Luna max/Luna max. `opus-codex` retains the Medium Codex executor, critic, and architect roles but uses `anthropic/claude-sonnet-5` for planner; `codex-opencodego` retains the Medium Codex default and architect roles; and `fable-opus-codex` uses the Pro Codex executor and architect roles with `anthropic/claude-opus-5:medium` for planner. The descriptive repeated local exact-edit evidence informs only selected executor-style TypeScript tasks; it does not evaluate or prove default, planner, architect, or critic performance. See [GPT-5.6 Codex preset benchmark](./gpt-5.6-codex-preset-benchmark.md). The Alibaba Pro role evidence and its limits are recorded separately in [Alibaba Token Plan Pro profile benchmark](./alibaba-token-plan-pro-profile-benchmark.md). Cursor Eco uses Composer 2.5 for every role; Medium keeps standard Composer for default/planning and spends the Fast premium on execution, criticism, and architecture; Pro uses Composer 2.5 Fast throughout. Composer does not expose a strength value through the current Cursor RPC, so these profiles use exact model IDs without inert generic effort suffixes. See [Cursor Composer profile tiers](./cursor-composer-profile-tiers.md). Effort suffixes are clamped to each model's supported thinking range at preview and activation time. Single-provider tiers pin each provider's current flagship (`zai/glm-5.2`, `kimi-code/kimi-k2.7-code`, `xiaomi/mimo-v2.5-pro`, `xai/grok-4.3`, `cursor/composer-2.5`, `minimax-code/MiniMax-M3`). User-defined profiles override built-ins by exact profile name.
Expand Down Expand Up @@ -284,7 +284,7 @@ providers:
- `auth`: `apiKey` (default), `none`, or `oauth`; for `models.yml` custom models, `oauth` is accepted by schema but does not waive the `apiKey` requirement
- `models.yml` is strict: unknown provider/model keys fail validation before provider dispatch, so stale keys such as `requestTransform` or `wireModelId` only work where this document lists them.
- `discovery.type`: `ollama`, `llama.cpp`, `lm-studio`, or `openai-models-list`
- `cacheRetention`: `none`, `short`, or `long`; request-time options win over model/modelOverride values, then provider values, then `GJC_CACHE_RETENTION`, then the runtime default. The runtime default is `short` for most providers, but the Anthropic provider defaults to `long` (`ttl: "1h"`) because the ~5m default is too fragile for long-running subagent workflows. The 1h marker is only emitted on the canonical Anthropic API (`api.anthropic.com`) for models advertising `supportsLongCacheRetention`; proxies, gateways, and incapable models fall back to the default ephemeral (~5m) breakpoint. For OpenAI Responses, this controls `prompt_cache_retention` only; it does not disable `prompt_cache_key` when a stable session id exists.
- `cacheRetention`: `none`, `short`, or `long`; request-time options win over model/modelOverride values, then provider values, then `GJC_CACHE_RETENTION`, then the runtime default. The runtime default is `short` for most providers, but the Anthropic provider defaults to `long` because the ~5m cache is fragile for long-running subagent workflows. Canonical Anthropic models emit `ttl: "1h"` when long retention is supported. Claude-family models on non-canonical Anthropic-compatible endpoints now use top-level automatic caching by default but omit `ttl` (the provider's ~5m default) unless `compat.supportsLongCacheRetention: true` explicitly opts the endpoint into 1-hour retention. For OpenAI Responses, this controls `prompt_cache_retention` only; it does not disable `prompt_cache_key` when a stable session id exists.

## OpenAI-compatible proxy configuration

Expand Down Expand Up @@ -803,7 +803,32 @@ Provider-level `compat` is the baseline; per-model `compat` is deep-merged on to

### Anthropic compatibility (`anthropic-messages`)

For `anthropic-messages` models the runtime uses a separate `AnthropicCompat` shape (`packages/ai/src/types.ts`). The `models.yml` schema currently exposes only the strict-tools opt-out as a top-level provider field (see below); the remaining Anthropic-side knobs (`disableAdaptiveThinking`, `supportsEagerToolInputStreaming`, `supportsLongCacheRetention`) are set by built-in catalog metadata and are not user-configurable from `models.yml`.
For `anthropic-messages` models, `compat.promptCacheMode` and `compat.supportsLongCacheRetention` are configurable at provider, model, and `modelOverrides` levels. Provider-level `compat` is the baseline; model and override values merge on top.

Prompt-cache modes:

- `automatic` — emit one top-level `cache_control` marker and let the Anthropic-compatible endpoint advance the breakpoint as the conversation grows.
- `explicit` — emit block-level breakpoints instead. Use this for endpoints that reject top-level `cache_control` but support Anthropic's explicit content-block markers.
- `none` — emit no generated Anthropic cache controls. Per-request or configured `cacheRetention: none` also disables generated caching.

Without an explicit mode, canonical Anthropic endpoints and Claude-family model ids default to `automatic`; unknown non-Claude compatible endpoints default to `none`. Non-canonical endpoints get the default ~5m lifetime unless they opt into `supportsLongCacheRetention: true`.

```yaml
providers:
corp-anthropic:
baseUrl: https://proxy.example.com/anthropic
apiKeyEnv: CORP_ANTHROPIC_API_KEY
api: anthropic-messages
compat:
promptCacheMode: explicit
supportsLongCacheRetention: false
models:
- id: claude-sonnet-4-5
contextWindow: 200000
maxTokens: 8192
```

Other Anthropic-side compatibility knobs such as `disableAdaptiveThinking` and `supportsEagerToolInputStreaming` remain built-in catalog metadata rather than `models.yml` fields. `disableStrictTools` stays a provider-level setting (below).

### Strict tool schemas (`disableStrictTools`)

Expand Down
Loading
Loading