项目文件夹

文件
Ali Khokhar 0d5bec3dcd Make proxy authentication bearer-only across every client (#1096)
## Problem

Proxy authentication selected the first of three credential headers, so
a stale `X-API-Key` could mask valid bearer authorization and leave
Claude CLI or IDE clients at their login gate. Fixes #902.

## Changes

| Before | After |
| --- | --- |
| FCC accepted three proxy credential headers and stripped legacy model
suffixes. | FCC accepts one exact `Authorization: Bearer` token without
mutation. |
| Conflicting provider credentials could override valid proxy
authorization. | Unrelated credential headers are ignored during proxy
authentication. |
| Codex catalog discovery and Pi catalog or inference paths used API-key
authentication. | Every FCC-owned Codex and Pi path uses bearer
authorization. |
| Authentication failures referred ambiguously to an API key. |
Authentication failures identify the proxy authentication token. |
| FCC reported version `4.2.0`. | FCC reports version `4.3.0`. |

<!-- greptile_comment -->

<details open><summary><h3>Greptile Summary</h3></summary>

This PR makes proxy authentication use bearer tokens only. The main
changes are:

- Replaced multi-header proxy auth with exact `Authorization: Bearer
<token>` checks.
- Updated protected route dependencies to use the renamed auth
dependency.
- Switched FCC-owned Codex and Pi catalog requests to bearer
authorization.
- Added Pi provider `authHeader` registration for inference requests.
- Updated smoke tests, API tests, docs, examples, and package metadata
for the new auth contract.
</details>

<h3>Confidence Score: 4/5</h3>

The changed auth flow is mostly consistent, but Codex inference can
still fail if its generated provider config sends API-key auth.

Server-side bearer parsing is direct and covered by updated tests. Route
protection appears preserved after the dependency rename. Codex catalog
auth was updated, but the inference path still depends on external
client header behavior. The version bump may understate a breaking
auth-contract change.

src/free_claude_code/api/dependencies.py,
src/free_claude_code/cli/launchers/codex.py, pyproject.toml

<details><summary><h3><a href="https://www.greptile.com/trex"><img
alt="T-Rex"
src="https://greptile-static-assets.s3.amazonaws.com/trex/trex_green.svg"
height="20" align="absmiddle"></a> T-Rex Logs</h3></summary>

**What T-Rex did**
- T-Rex executed targeted proxy authentication validation to verify
bearer token behavior before and after the change.
- T-Rex compared pre-change and post-change test results, confirming 31
passed before and 39 passed after, and validating HTTP 200 for exact
bearer with unrelated X-API-Key plus HTTP 401 for missing or invalid
tokens.

<a
href="https://app.greptile.com/trex/runs/14225355/artifacts"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://greptile-static-assets.s3.amazonaws.com/badges/ViewAllArtifactsDark.svg?v=4"><source
media="(prefers-color-scheme: light)"
srcset="https://greptile-static-assets.s3.amazonaws.com/badges/ViewAllArtifacts.svg?v=4"><img
alt="View all artifacts"
src="https://greptile-static-assets.s3.amazonaws.com/badges/ViewAllArtifacts.svg?v=4"></picture></a>

<sub><a href="https://www.greptile.com/trex"><img alt="T-Rex"
src="https://greptile-static-assets.s3.amazonaws.com/trex/trex_green.svg"
height="14" align="absmiddle"></a> Ran code and verified through
T-Rex</sub>
</details>

<details open><summary><h3>Important Files Changed</h3></summary>

| Filename | Overview |
|----------|----------|
| src/free_claude_code/api/dependencies.py | Replaces proxy
authentication with exact bearer-token validation and new error details.
|
| src/free_claude_code/api/routes.py | Updates protected route
dependencies to call the renamed auth dependency. |
| src/free_claude_code/cli/launchers/codex.py | Changes Codex catalog
discovery to send bearer auth while leaving inference auth delegated
through Codex config. |
| src/free_claude_code/cli/launchers/pi_extension.ts | Changes Pi
catalog discovery to bearer auth and registers the provider with
`authHeader` enabled. |
| smoke/lib/config.py | Updates smoke helper auth headers to emit bearer
authorization. |
| pyproject.toml | Bumps the package version from 4.2.0 to 4.3.0. |

</details>

<details open><summary><h3>Sequence Diagram</h3></summary>

<a href="#gh-light-mode-only">

```mermaid
%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
participant Codex
participant Launcher as FCC Codex launcher
participant API as FCC API
Launcher->>API: GET /v1/models with Authorization Bearer token
API-->>Launcher: Catalog response
Launcher-->>Codex: Raw token in FCC_CODEX_API_KEY
Codex->>API: POST /v1/responses with client-built auth
alt Client sends bearer authorization
    API-->>Codex: Accepted
else Client sends API-key auth
    API-->>Codex: 401 proxy auth failure
end
```

</a>
<a href="#gh-dark-mode-only">

```mermaid
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
participant Codex
participant Launcher as FCC Codex launcher
participant API as FCC API
Launcher->>API: GET /v1/models with Authorization Bearer token
API-->>Launcher: Catalog response
Launcher-->>Codex: Raw token in FCC_CODEX_API_KEY
Codex->>API: POST /v1/responses with client-built auth
alt Client sends bearer authorization
    API-->>Codex: Accepted
else Client sends API-key auth
    API-->>Codex: 401 proxy auth failure
end
```

</a>
</details>

<a
href="https://app.greptile.com/api/ide/codex?prompt=IMPORTANT%3A%20Work%20in%20the%20repository%20%22alishahryar1%2Ffree-claude-code%22%20on%20the%20existing%20branch%20%22ali%2Fcanonical-proxy-bearer-auth%22.%20Checkout%20that%20branch%20%E2%80%94%20do%20NOT%20create%20a%20new%20branch%20or%20open%20a%20new%20PR.%20Push%20your%20changes%20to%20%22ali%2Fcanonical-proxy-bearer-auth%22.%0A%0AFix%20the%20following%202%20code%20review%20issues.%20Work%20through%20them%20one%20at%20a%20time%2C%20proposing%20concise%20fixes.%0A%0A---%0A%0A%23%23%23%20Issue%201%20of%202%0Asrc%2Ffree_claude_code%2Fapi%2Fdependencies.py%3A56%0A**Codex%20Inference%20Still%20Delegates%20Auth**%0A%0AWhen%20Codex%20launches%2C%20FCC%20now%20sends%20bearer%20auth%20only%20for%20its%20own%20%60%2Fv1%2Fmodels%60%20catalog%20request%2C%20but%20inference%20still%20depends%20on%20Codex%20turning%20the%20raw%20%60FCC_CODEX_API_KEY%60%20value%20into%20the%20same%20bearer%20header.%20If%20Codex%20sends%20that%20env%20key%20as%20an%20API-key%20header%2C%20this%20server%20branch%20treats%20the%20request%20as%20missing%20proxy%20auth%20and%20%60%2Fv1%2Fresponses%60%20fails%20with%20401%20even%20though%20catalog%20discovery%20succeeded.%0A%0A%23%23%23%20Issue%202%20of%202%0Apyproject.toml%3A7%0A**Breaking%20Auth%20Contract%20Understated**%0A%0AThis%20change%20removes%20previously%20accepted%20proxy%20credential%20shapes%2C%20including%20%60X-API-Key%60%2C%20%60anthropic-auth-token%60%2C%20and%20suffixed%20bearer%20tokens%2C%20but%20the%20package%20version%20only%20moves%20from%20%604.2.0%60%20to%20%604.3.0%60.%20Existing%20users%20can%20upgrade%20within%20the%20same%20major%20line%20and%20have%20every%20protected%20endpoint%20start%20returning%20401%20until%20their%20clients%20are%20reconfigured%2C%20which%20does%20not%20match%20the%20repository%20guidance%20for%20incompatible%20API%20or%20CLI%20behavior.%0A%0A&repo=alishahryar1%2Ffree-claude-code&pr=1096&platform=github"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://greptile-static-assets.s3.amazonaws.com/badges/FixAllInCodexDark.svg?v=6"><source
media="(prefers-color-scheme: light)"
srcset="https://greptile-static-assets.s3.amazonaws.com/badges/FixAllInCodex.svg?v=6"><img
alt="Fix All in Codex"
src="https://greptile-static-assets.s3.amazonaws.com/badges/FixAllInCodex.svg?v=6"></picture></a>

<sub>Reviews (1): Last reviewed commit: ["fix: make proxy auth
bearer-only"](https://github.com/alishahryar1/free-claude-code/commit/c437ee0e23dc8f8411272a8dcfcf1ff69f315857)
| [Re-trigger
Greptile](https://app.greptile.com/api/retrigger?id=43791345)</sub>

> Greptile also left **2 inline comments** on this PR.

**Context used:**

- Context used - CLAUDE.md
([source](https://app.greptile.com/alishahryar1/github/Alishahryar1/free-claude-code/-/custom-context?memory=d2fd24d8-0dec-4faf-8ee4-e085e215a2f8))

<!-- /greptile_comment -->
2026-07-13 04:26:20 -07:00

753 行
24 KiB
Python

"""Reusable product E2E smoke drivers."""
import asyncio
import json
import os
import subprocess
import time
import uuid
import wave
from collections.abc import AsyncGenerator, Awaitable, Callable, Iterator
from contextlib import contextmanager
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
import httpx
import pytest
from free_claude_code.cli.claude_env import build_claude_proxy_env
from free_claude_code.config.provider_catalog import SUPPORTED_PROVIDER_IDS
from free_claude_code.core.anthropic.stream_contracts import (
SSEEvent,
assert_anthropic_stream_contract,
event_index,
has_tool_use,
parse_sse_lines,
text_content,
)
from free_claude_code.messaging.models import IncomingMessage, MessageScope
from free_claude_code.messaging.session import SessionStore
from free_claude_code.messaging.voice import VoiceCancellationResult
from free_claude_code.messaging.workflow import MessagingWorkflow
from smoke.lib.child_process import run_captured_text
from smoke.lib.config import ProviderModel, SmokeConfig, auth_headers
from smoke.lib.server import RunningServer, start_server
from smoke.lib.skips import fail_missing_env
@dataclass(slots=True)
class ConversationTurn:
request: dict[str, Any]
events: list[SSEEvent]
@property
def assistant_content(self) -> list[dict[str, Any]]:
return assistant_content_from_events(self.events)
@property
def text(self) -> str:
return text_content(self.events)
class SmokeServerDriver:
"""Start a local proxy server for a product scenario."""
def __init__(
self,
config: SmokeConfig,
*,
name: str,
env_overrides: dict[str, str] | None = None,
command: list[str] | None = None,
) -> None:
self.config = config
self.name = name
self.env_overrides = env_overrides
self.command = command
@contextmanager
def run(self) -> Iterator[RunningServer]:
with start_server(
self.config,
env_overrides=self.env_overrides,
command=self.command,
name=self.name,
) as server:
yield server
class ConversationDriver:
"""Drive multi-turn Anthropic-compatible conversations through the server."""
def __init__(self, server: RunningServer, config: SmokeConfig) -> None:
self.server = server
self.config = config
self.messages: list[dict[str, Any]] = []
self.turns: list[ConversationTurn] = []
def ask(
self,
text: str,
*,
model: str = "fcc-smoke-default",
max_tokens: int = 256,
extra: dict[str, Any] | None = None,
headers: dict[str, str] | None = None,
append_assistant: bool = True,
) -> ConversationTurn:
self.messages.append({"role": "user", "content": text})
payload = {
"model": model,
"max_tokens": max_tokens,
"messages": list(self.messages),
}
if extra:
payload.update(extra)
turn = self.stream(payload, headers=headers)
if append_assistant:
self.messages.append(
{"role": "assistant", "content": turn.assistant_content or turn.text}
)
return turn
def stream(
self,
payload: dict[str, Any],
*,
headers: dict[str, str] | None = None,
) -> ConversationTurn:
request_headers = headers or auth_headers()
with httpx.stream(
"POST",
f"{self.server.base_url}/v1/messages",
headers=request_headers,
json=payload,
timeout=self.config.timeout_s,
) as response:
if response.status_code != 200:
body = response.read().decode("utf-8", errors="replace")
raise AssertionError(
f"stream request failed: HTTP {response.status_code} {body[:1000]}"
)
events = parse_sse_lines(response.iter_lines())
assert_anthropic_stream_contract(events)
turn = ConversationTurn(payload, events)
self.turns.append(turn)
return turn
def stream_expect_http_error(
self,
payload: dict[str, Any],
*,
expected_status: int,
) -> dict[str, Any]:
response = httpx.post(
f"{self.server.base_url}/v1/messages",
headers=auth_headers(),
json=payload,
timeout=self.config.timeout_s,
)
assert response.status_code == expected_status, response.text
return response.json()
class ProviderMatrixDriver:
"""Resolve provider models and enforce matrix semantics for product smoke."""
ALL_PROVIDERS: tuple[str, ...] = SUPPORTED_PROVIDER_IDS
def __init__(self, config: SmokeConfig) -> None:
self.config = config
def configured_models(self) -> list[ProviderModel]:
return self.config.provider_models()
def provider_smoke_models(self) -> list[ProviderModel]:
selected = self.config.provider_matrix
missing_selected = [
provider
for provider in selected
if provider in self.ALL_PROVIDERS
and not self.config.has_provider_configuration(provider)
]
if missing_selected:
fail_missing_env(
"selected providers are not configured: "
+ ", ".join(sorted(missing_selected))
)
models = self.config.provider_smoke_models()
if not models and os.getenv("FCC_ALLOW_NO_PROVIDER_SMOKE") != "1":
fail_missing_env(
"no configured provider smoke models; set FCC_ALLOW_NO_PROVIDER_SMOKE=1 "
"only for no-provider smoke collection"
)
return models
def first_model(self) -> ProviderModel:
models = self.provider_smoke_models()
if not models:
pytest.skip("missing_env: no configured provider model")
return models[0]
class ClientProtocolDriver:
"""Build recorded/representative client protocol requests."""
@staticmethod
def vscode_headers() -> dict[str, str]:
headers = auth_headers()
headers.update(
{
"anthropic-beta": "messages-2023-12-15",
"user-agent": "Claude-Code-VSCode product smoke",
}
)
return headers
@staticmethod
def jetbrains_headers() -> dict[str, str]:
headers = auth_headers()
headers["user-agent"] = "JetBrains-ACP product smoke"
return headers
@staticmethod
def adaptive_thinking_payload() -> dict[str, Any]:
return {
"model": "claude-opus-4-7",
"max_tokens": 256,
"messages": [
{"role": "user", "content": "hello"},
{
"role": "assistant",
"content": [
{"type": "thinking", "thinking": "unsigned thought"},
{"type": "redacted_thinking", "data": "opaque"},
{"type": "text", "text": "Hello."},
],
},
{"role": "user", "content": "Reply with exactly FCC_SMOKE_CLIENT"},
],
"thinking": {"type": "adaptive", "budget_tokens": 1024},
}
@staticmethod
def tool_result_payload() -> dict[str, Any]:
return {
"model": "claude-sonnet-4-5-20250929",
"max_tokens": 256,
"messages": [
{"role": "user", "content": "Use echo_smoke once."},
{
"role": "assistant",
"content": [
{
"type": "tool_use",
"id": "toolu_client_smoke",
"name": "echo_smoke",
"input": {"value": "FCC_SMOKE_CLIENT"},
}
],
},
{
"role": "user",
"content": [
{
"type": "tool_result",
"tool_use_id": "toolu_client_smoke",
"content": "FCC_SMOKE_CLIENT",
}
],
},
],
"tools": [echo_tool_schema()],
"thinking": {"type": "adaptive"},
}
@staticmethod
def run_claude_prompt(
*,
claude_bin: str,
server: RunningServer,
config: SmokeConfig,
cwd: Path,
prompt: str,
model: str | None = None,
) -> subprocess.CompletedProcess[str]:
env = build_claude_proxy_env(
proxy_root_url=server.base_url,
auth_token=config.settings.anthropic_auth_token,
base_env=os.environ,
)
command = [
claude_bin,
"--bare",
"--no-session-persistence",
"--tools",
"",
"--system-prompt",
"Reply with exactly the requested smoke token and no other text.",
]
if model is not None:
command.extend(["--model", model])
command.extend(["-p", prompt])
return run_captured_text(
command,
cwd=cwd,
env=env,
timeout=config.timeout_s,
check=False,
)
class FakePlatform:
"""In-memory platform that exercises the real message handler."""
def __init__(self, name: str) -> None:
self.name = name
self.handler: Callable[[IncomingMessage], Awaitable[None]] | None = None
self.sent: list[dict[str, Any]] = []
self.edits: list[dict[str, Any]] = []
self.deletes: list[dict[str, Any]] = []
self._counter = 0
self._tasks: list[asyncio.Future[Any]] = []
self._pending_voice: dict[
tuple[MessageScope, str], VoiceCancellationResult
] = {}
async def start(self) -> None:
return None
async def quiesce(self) -> None:
return None
async def close(self) -> None:
for task in self._tasks:
if not task.done():
task.cancel()
if self._tasks:
await asyncio.gather(*self._tasks, return_exceptions=True)
@property
def is_connected(self) -> bool:
return True
def on_message(self, handler: Callable[[IncomingMessage], Awaitable[None]]) -> None:
self.handler = handler
def continue_message_sequence_after(self, previous: FakePlatform) -> None:
"""Model platform-owned message IDs surviving an FCC restart."""
self._counter = previous._counter
async def emit(self, incoming: IncomingMessage) -> None:
assert self.handler is not None
await self.handler(incoming)
def fire_and_forget(self, task: Awaitable[Any]) -> None:
self._tasks.append(asyncio.ensure_future(task))
async def send_message(
self,
chat_id: str,
text: str,
reply_to: str | None = None,
parse_mode: str | None = None,
message_thread_id: str | None = None,
) -> str:
self._counter += 1
message_id = f"{self.name}_msg_{self._counter}"
self.sent.append(
{
"chat_id": chat_id,
"message_id": message_id,
"text": text,
"reply_to": reply_to,
"parse_mode": parse_mode,
"message_thread_id": message_thread_id,
}
)
return message_id
async def edit_message(
self,
chat_id: str,
message_id: str,
text: str,
parse_mode: str | None = None,
) -> None:
self.edits.append(
{
"chat_id": chat_id,
"message_id": message_id,
"text": text,
"parse_mode": parse_mode,
}
)
async def delete_message(self, chat_id: str, message_id: str) -> None:
self.deletes.append({"chat_id": chat_id, "message_id": message_id})
async def queue_send_message(
self,
chat_id: str,
text: str,
reply_to: str | None = None,
parse_mode: str | None = None,
fire_and_forget: bool = True,
message_thread_id: str | None = None,
) -> str | None:
message_id = await self.send_message(
chat_id,
text,
reply_to=reply_to,
parse_mode=parse_mode,
message_thread_id=message_thread_id,
)
return None if fire_and_forget else message_id
async def queue_edit_message(
self,
chat_id: str,
message_id: str,
text: str,
parse_mode: str | None = None,
fire_and_forget: bool = True,
) -> None:
await self.edit_message(chat_id, message_id, text, parse_mode=parse_mode)
async def queue_delete_messages(
self,
chat_id: str,
message_ids: list[str],
fire_and_forget: bool = True,
) -> None:
for message_id in message_ids:
await self.delete_message(chat_id, message_id)
def seed_pending_voice(
self, chat_id: str, voice_message_id: str, status_message_id: str
) -> None:
scope = MessageScope(platform=self.name, chat_id=chat_id)
result = VoiceCancellationResult(
scope=scope,
voice_message_id=voice_message_id,
status_message_id=status_message_id,
delete_message_ids=frozenset({voice_message_id, status_message_id}),
)
self._pending_voice[(scope, voice_message_id)] = result
self._pending_voice[(scope, status_message_id)] = result
async def cancel_pending_voice(
self, scope: MessageScope, reply_id: str
) -> VoiceCancellationResult | None:
result = self._pending_voice.get((scope, reply_id))
if result is None:
return None
self._pending_voice.pop((scope, result.voice_message_id), None)
if result.status_message_id is not None:
self._pending_voice.pop((scope, result.status_message_id), None)
delete_message_ids = {result.voice_message_id, result.status_message_id}
if reply_id == result.status_message_id:
delete_message_ids = {result.status_message_id}
return VoiceCancellationResult(
scope=result.scope,
voice_message_id=result.voice_message_id,
status_message_id=result.status_message_id,
delete_message_ids=frozenset(
message_id
for message_id in delete_message_ids
if message_id is not None
),
)
async def cancel_all_pending_voices(
self,
) -> tuple[VoiceCancellationResult, ...]:
results = tuple(
{
(result.scope, result.voice_message_id): result
for result in self._pending_voice.values()
}.values()
)
self._pending_voice.clear()
return results
async def cancel_pending_voices_in_scope(
self,
scope: MessageScope,
) -> tuple[VoiceCancellationResult, ...]:
results = tuple(
{
result.voice_message_id: result
for (entry_scope, _reference_id), result in self._pending_voice.items()
if entry_scope == scope
}.values()
)
for result in results:
self._pending_voice.pop((scope, result.voice_message_id), None)
if result.status_message_id is not None:
self._pending_voice.pop((scope, result.status_message_id), None)
return results
@property
def pending_voice_count(self) -> int:
return len(
{
(result.scope, result.voice_message_id)
for result in self._pending_voice.values()
}
)
class FakeCLISession:
def __init__(self, events: list[dict[str, Any]]) -> None:
self.events = events
self.calls: list[dict[str, Any]] = []
self.is_busy = False
async def start_task(
self, prompt: str, session_id: str | None = None, fork_session: bool = False
) -> AsyncGenerator[dict[str, Any]]:
self.calls.append(
{"prompt": prompt, "session_id": session_id, "fork_session": fork_session}
)
self.is_busy = True
try:
for event in self.events:
await asyncio.sleep(0)
yield event
finally:
self.is_busy = False
class FakeCLIManager:
def __init__(self, event_batches: list[list[dict[str, Any]]] | None = None) -> None:
self.event_batches = event_batches or [default_cli_events("fake_session_1")]
self.sessions: list[FakeCLISession] = []
self.registered: list[tuple[str, str]] = []
self.removed: list[str] = []
self.stopped = False
async def get_or_create_session(
self, session_id: str | None = None
) -> tuple[FakeCLISession, str, bool]:
index = len(self.sessions)
events = self.event_batches[min(index, len(self.event_batches) - 1)]
session = FakeCLISession(events)
self.sessions.append(session)
return session, session_id or f"pending_{index}", session_id is None
async def register_real_session_id(
self, temp_id: str, real_session_id: str
) -> bool:
self.registered.append((temp_id, real_session_id))
return True
async def stop_all(self) -> None:
self.stopped = True
async def remove_session(self, session_id: str) -> bool:
self.removed.append(session_id)
return True
def get_stats(self) -> dict[str, int]:
return {"active_sessions": len(self.sessions), "pending_sessions": 0}
@dataclass(slots=True)
class FakePlatformDriver:
platform_name: str
tmp_path: Path
event_batches: list[list[dict[str, Any]]] | None = None
platform: FakePlatform = field(init=False)
cli_manager: FakeCLIManager = field(init=False)
session_store: SessionStore = field(init=False)
workflow: MessagingWorkflow = field(init=False)
def __post_init__(self) -> None:
self.platform = FakePlatform(self.platform_name)
self.cli_manager = FakeCLIManager(self.event_batches)
self.session_store = SessionStore(
storage_path=str(self.tmp_path / f"{self.platform_name}-sessions.json")
)
self.workflow = MessagingWorkflow(
self.platform,
self.cli_manager,
self.session_store,
platform_name=self.platform_name,
voice_cancellation=self.platform,
)
self.platform.on_message(self.workflow.handle_message)
async def send(
self,
text: str,
*,
chat_id: str = "chat_1",
message_id: str | None = None,
reply_to: str | None = None,
) -> IncomingMessage:
incoming = await self.emit(
text,
chat_id=chat_id,
message_id=message_id,
reply_to=reply_to,
)
await self.wait_for_idle()
return incoming
async def emit(
self,
text: str,
*,
chat_id: str = "chat_1",
message_id: str | None = None,
reply_to: str | None = None,
) -> IncomingMessage:
"""Deliver a message without waiting for background claims to finish."""
incoming = IncomingMessage(
text=text,
chat_id=chat_id,
user_id="user_1",
message_id=message_id or f"in_{uuid.uuid4().hex[:8]}",
platform=self.platform_name,
reply_to_message_id=reply_to,
)
await self.platform.emit(incoming)
return incoming
async def wait_for_idle(self, *, timeout_s: float = 5.0) -> None:
deadline = time.monotonic() + timeout_s
while time.monotonic() < deadline:
pending = [task for task in self.platform._tasks if not task.done()]
if not pending and await self._all_tree_nodes_terminal():
self.session_store.flush_pending_save()
return
await asyncio.sleep(0.02)
raise AssertionError("fake platform did not become idle")
async def _all_tree_nodes_terminal(self) -> bool:
snapshot = await self.workflow.tree_queue.snapshot()
for tree in snapshot.trees.values():
for node in tree.nodes.values():
if node.get("state") in {"pending", "in_progress"}:
return False
return True
class VoiceFixtureDriver:
@staticmethod
def write_tone_wav(path: Path) -> None:
import math
sample_rate = 16000
duration_s = 0.25
amplitude = 8000
frames = bytearray()
for i in range(int(sample_rate * duration_s)):
sample = int(amplitude * math.sin(2 * math.pi * 440 * i / sample_rate))
frames.extend(sample.to_bytes(2, byteorder="little", signed=True))
with wave.open(str(path), "wb") as wav:
wav.setnchannels(1)
wav.setsampwidth(2)
wav.setframerate(sample_rate)
wav.writeframes(bytes(frames))
def echo_tool_schema() -> dict[str, Any]:
return {
"name": "echo_smoke",
"description": "Echo a test value.",
"input_schema": {
"type": "object",
"properties": {"value": {"type": "string"}},
"required": ["value"],
},
}
def assistant_content_from_events(events: list[SSEEvent]) -> list[dict[str, Any]]:
blocks: dict[int, dict[str, Any]] = {}
block_order: list[int] = []
for event in events:
if event.event == "content_block_start":
index = event_index(event)
block = event.data.get("content_block", {})
if isinstance(block, dict):
blocks[index] = dict(block)
block_order.append(index)
continue
if event.event == "content_block_delta":
index = event_index(event)
block = blocks.get(index)
delta = event.data.get("delta", {})
if not isinstance(block, dict) or not isinstance(delta, dict):
continue
delta_type = delta.get("type")
if delta_type == "text_delta":
block["text"] = str(block.get("text", "")) + str(delta.get("text", ""))
elif delta_type == "thinking_delta":
block["thinking"] = str(block.get("thinking", "")) + str(
delta.get("thinking", "")
)
elif delta_type == "input_json_delta":
block["_partial_json"] = str(block.get("_partial_json", "")) + str(
delta.get("partial_json", "")
)
content: list[dict[str, Any]] = []
for index in block_order:
block = blocks[index]
if block.get("type") == "tool_use":
partial = str(block.pop("_partial_json", ""))
if partial:
try:
block["input"] = json.loads(partial)
except json.JSONDecodeError:
block["input"] = {}
content.append(block)
return content
def tool_use_blocks(events: list[SSEEvent]) -> list[dict[str, Any]]:
return [
block
for block in assistant_content_from_events(events)
if block.get("type") == "tool_use"
]
def default_cli_events(session_id: str) -> list[dict[str, Any]]:
return [
{"type": "session_info", "session_id": session_id},
{
"type": "assistant",
"message": {
"content": [
{"type": "thinking", "thinking": "Inspect the request."},
{
"type": "tool_use",
"id": "toolu_fake",
"name": "Read",
"input": {"file_path": "README.md"},
},
{
"type": "tool_result",
"tool_use_id": "toolu_fake",
"content": "Free Claude Code",
},
{"type": "text", "text": "Fake platform answer."},
]
},
},
{"type": "exit", "code": 0, "stderr": None},
]
def assert_product_stream(events: list[SSEEvent]) -> None:
assert_anthropic_stream_contract(events)
assert text_content(events).strip() or has_tool_use(events), (
"product stream emitted neither text nor tool_use"
)