@oneciel-ai/ciel-runtime 0.2.49 → 0.2.50
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ciel_runtime.py +16 -21
- package/ciel_runtime_support/claude_router.py +6 -1
- package/ciel_runtime_support/config_migrations.py +11 -0
- package/ciel_runtime_support/protocols/openai_responses.py +73 -1
- package/ciel_runtime_support/providers/opencode.py +313 -27
- package/ciel_runtime_support/providers/opencode_go.py +3 -0
- package/ciel_runtime_support/runtime_constants.py +1 -1
- package/ciel_runtime_support/runtime_paths.py +11 -1
- package/ciel_runtime_support/test_isolation_guard.py +111 -0
- package/ciel_runtime_support/tool_dialects.py +24 -0
- package/docs/journal/2026/09/18/diagnostics/opencode/free-tier-gate-identified.okf +23 -0
- package/docs/journal/2026/09/18/fixes/runtime/test-sweep-kills-live-session.okf +17 -0
- package/docs/journal/2026/09/18/providers/opencode/free-tier-gate-and-exact-client-identity.okf +31 -0
- package/package.json +1 -1
package/ciel_runtime.py
CHANGED
|
@@ -286,7 +286,7 @@ from ciel_runtime_support.protocols.ollama_chat import anthropic_system_to_ollam
|
|
|
286
286
|
from ciel_runtime_support.protocols.ollama_response import project_ollama_response, project_openai_chat_response
|
|
287
287
|
from ciel_runtime_support.protocols.openai_reasoning import OpenAiReasoningPolicy, anthropic_tool_choice_to_openai, openai_reasoning_to_anthropic_thinking_block
|
|
288
288
|
from ciel_runtime_support.protocols.openai_chat_compat import anthropic_message_to_openai_chat_completion, openai_chat_to_anthropic_messages
|
|
289
|
-
from ciel_runtime_support.protocols.openai_responses import anthropic_messages_to_openai_responses, openai_response_to_anthropic_message
|
|
289
|
+
from ciel_runtime_support.protocols.openai_responses import anthropic_messages_to_openai_responses, openai_response_to_anthropic_message, responses_client_tool_names
|
|
290
290
|
from ciel_runtime_support.protocols.pseudo_tool_history import PseudoToolHistoryServices, find_pseudo_xml_tool_start, parse_xml_pseudo_tool_calls, sanitize_assistant_pseudo_tool_history
|
|
291
291
|
from ciel_runtime_support.protocols.tool_result_projection import ToolResultProjectionServices, project_tool_result
|
|
292
292
|
from ciel_runtime_support.provider_adapters import PROVIDER_ADAPTERS, PROVIDER_ALIASES, PROVIDER_LABELS, provider_default_configurations
|
|
@@ -499,6 +499,8 @@ from ciel_runtime_support.synthetic_tool_policy import ForcedPlanModeController,
|
|
|
499
499
|
from ciel_runtime_support.timeout_profile import TimeoutProfileApi, TimeoutProfilePorts, TimeoutProfileService, TimeoutProfileSettings
|
|
500
500
|
from ciel_runtime_support.tool_dialects import TOOL_DIALECTS
|
|
501
501
|
from ciel_runtime_support.tool_dialects import match_available_tool_name as _match_available_tool_name
|
|
502
|
+
from ciel_runtime_support.tool_dialects import match_gate_tool_equivalent as _match_gate_tool_equivalent
|
|
503
|
+
from ciel_runtime_support import test_isolation_guard
|
|
502
504
|
from ciel_runtime_support.tool_dialects import mcp_server_normalized_key
|
|
503
505
|
from ciel_runtime_support.tool_exposure_policy import ToolExposurePolicy, ToolExposurePorts
|
|
504
506
|
from ciel_runtime_support.tool_guard_hooks import TOOL_GUARD_EVENTS_WITH_TOOL_MATCHER # noqa: F401 - compatibility export
|
|
@@ -1066,7 +1068,15 @@ _mcp_tool_name_server_normalized_key = mcp_server_normalized_key
|
|
|
1066
1068
|
|
|
1067
1069
|
def resolve_emitted_tool_name(raw_name: str, source_body: dict[str, Any] | None) -> str:
|
|
1068
1070
|
available = tool_names_in_body(source_body or {}) if isinstance(source_body, dict) else set()
|
|
1069
|
-
|
|
1071
|
+
if isinstance(source_body, dict):
|
|
1072
|
+
# Codex carries its tools in additional_tools, so the plain top-level
|
|
1073
|
+
# scan sees nothing (see responses_client_tool_names).
|
|
1074
|
+
available |= responses_client_tool_names(source_body)
|
|
1075
|
+
matched = _match_available_tool_name(raw_name, available) or _match_gate_tool_equivalent(raw_name, available)
|
|
1076
|
+
if available:
|
|
1077
|
+
# Never invent a name the client did not declare.
|
|
1078
|
+
return matched or raw_name
|
|
1079
|
+
return matched or _fuzzy_match_tool_name(raw_name) or raw_name
|
|
1070
1080
|
|
|
1071
1081
|
ANTHROPIC_PASSTHROUGH_TOOL_INPUT_REPAIR_TOOLS = {"AskUserQuestion"}
|
|
1072
1082
|
def should_repair_anthropic_passthrough_tool_input(provider: str, raw_name: str, source_body: dict[str, Any] | None) -> bool: return provider_tool_policy().should_repair_passthrough_input(provider, {}, raw_name, source_body)
|
|
@@ -3800,26 +3810,11 @@ def process_tree_controller() -> ProcessTreeController: return ProcessTreeContro
|
|
|
3800
3810
|
def descendant_pids(pid: int) -> list[int]: return process_tree_controller().descendant_pids(pid)
|
|
3801
3811
|
def parent_pid_and_command(pid: int) -> tuple[int, str] | None: return process_tree_controller().parent_pid_and_command(pid)
|
|
3802
3812
|
def ciel_runtime_client_wrapper_parent_pids(pid: int) -> list[int]: return process_tree_controller().client_wrapper_parent_pids(pid)
|
|
3803
|
-
def terminate_pid_tree(pid: int, label: str, quiet: bool = False) -> bool: return process_tree_controller().terminate_tree(
|
|
3804
|
-
def terminate_active_router_clients(reason: str, active_clients: list[int] | None = None, quiet: bool = True) -> bool: return router_client_registry().terminate_active(
|
|
3805
|
-
|
|
3806
|
-
def _unisolated_test_process() -> bool:
|
|
3807
|
-
"""Return True when a test runner could touch the user's live state.
|
|
3808
|
-
|
|
3809
|
-
The supported test entrypoint sets ``CIEL_RUNTIME_TEST_ISOLATED`` before
|
|
3810
|
-
importing this module. Direct unittest/pytest discovery previously bound
|
|
3811
|
-
CONFIG_DIR to the user's real profile and a launch test terminated an
|
|
3812
|
-
active CIELARVIS Runtime client. Destructive process cleanup must fail
|
|
3813
|
-
closed even when a developer invokes the lower-level test command.
|
|
3814
|
-
"""
|
|
3815
|
-
if parse_bool(os.environ.get("CIEL_RUNTIME_TEST_ISOLATED"), False):
|
|
3816
|
-
return False
|
|
3817
|
-
if os.environ.get("PYTEST_CURRENT_TEST"):
|
|
3818
|
-
return True
|
|
3819
|
-
if "unittest" in sys.modules or "pytest" in sys.modules:
|
|
3820
|
-
return True
|
|
3821
|
-
return any(Path(str(argument)).name.startswith("test_") for argument in sys.argv[1:])
|
|
3813
|
+
def terminate_pid_tree(pid: int, label: str, quiet: bool = False) -> bool: return test_isolation_guard.terminate_tree(pid, label, terminate=lambda tree_pid, tree_label, quiet=False: process_tree_controller().terminate_tree(tree_pid, tree_label, quiet=quiet), unisolated=_unisolated_test_process, log=router_log, quiet=quiet)
|
|
3814
|
+
def terminate_active_router_clients(reason: str, active_clients: list[int] | None = None, quiet: bool = True) -> bool: return test_isolation_guard.terminate_clients(reason, active_clients, terminate=lambda r, c, quiet=True: router_client_registry().terminate_active(r, c, quiet=quiet), unisolated=_unisolated_test_process, log=router_log, quiet=quiet)
|
|
3822
3815
|
|
|
3816
|
+
def _unisolated_test_process() -> bool: return test_isolation_guard.unisolated_test_process()
|
|
3817
|
+
def _test_runner_arguments(arguments: list[str]) -> bool: return test_isolation_guard.test_runner_arguments(arguments)
|
|
3823
3818
|
def terminate_existing_router_clients_for_launch(reason: str, quiet: bool = True) -> bool:
|
|
3824
3819
|
if _unisolated_test_process():
|
|
3825
3820
|
return False
|
|
@@ -285,7 +285,12 @@ def handle_claude_messages_post(
|
|
|
285
285
|
upstream_model = resolve_requested_model(provider, pcfg, body.get("model"))
|
|
286
286
|
selected_protocol = select_provider_protocol(provider, pcfg, "anthropic_messages", upstream_model)
|
|
287
287
|
provider_label = provider_labels.get(provider, provider)
|
|
288
|
-
|
|
288
|
+
# Responses-family models (zen muse-spark, ...) have no Anthropic
|
|
289
|
+
# endpoint of their own, so a Claude Code request is converted to the
|
|
290
|
+
# Responses wire and its answer back to Anthropic instead of being
|
|
291
|
+
# refused (user request, 2026-09-18). Providers that do expose an
|
|
292
|
+
# Anthropic endpoint still select it in select_provider_protocol.
|
|
293
|
+
if selected_protocol == "openai_responses":
|
|
289
294
|
event_bus.publish(
|
|
290
295
|
level="info",
|
|
291
296
|
category="upstream.request",
|
|
@@ -56,6 +56,17 @@ def apply_config_migrations(cfg: dict[str, Any], *, policy: ConfigMigrationPolic
|
|
|
56
56
|
custom.append("stealth/union-alpha")
|
|
57
57
|
migrations[marker] = True
|
|
58
58
|
|
|
59
|
+
marker = "opencode_custom_tools_as_functions_20260918"
|
|
60
|
+
if not migrations.get(marker):
|
|
61
|
+
providers = cfg.get("providers") if isinstance(cfg.get("providers"), dict) else {}
|
|
62
|
+
for name in ("opencode", "opencode-go"):
|
|
63
|
+
pcfg = providers.get(name)
|
|
64
|
+
if isinstance(pcfg, dict) and pcfg.get("responses_custom_tools_as_functions") is None:
|
|
65
|
+
# The zen Responses family refuses type: custom tools; the
|
|
66
|
+
# bridge declares them as functions and maps calls back.
|
|
67
|
+
pcfg["responses_custom_tools_as_functions"] = True
|
|
68
|
+
migrations[marker] = True
|
|
69
|
+
|
|
59
70
|
marker = "openrouter_pareto_catalog_20260917"
|
|
60
71
|
if not migrations.get(marker):
|
|
61
72
|
providers = cfg.get("providers") if isinstance(cfg.get("providers"), dict) else {}
|
|
@@ -205,6 +205,38 @@ def _responses_source_tools(body: dict[str, Any] | None) -> Any:
|
|
|
205
205
|
return declarations[0] if len(declarations) == 1 else None
|
|
206
206
|
|
|
207
207
|
|
|
208
|
+
def responses_client_tool_names(body: dict[str, Any] | None) -> set[str]:
|
|
209
|
+
"""Tool names a Responses request makes callable, in projected spelling.
|
|
210
|
+
|
|
211
|
+
Codex carries its catalogue either at the top level or inside the
|
|
212
|
+
``additional_tools`` input item, whose namespace members are callable under
|
|
213
|
+
the aliased ``namespace__member`` name. The response projection emits calls
|
|
214
|
+
under those same names, so this is the vocabulary a replayed call can use.
|
|
215
|
+
"""
|
|
216
|
+
|
|
217
|
+
if not isinstance(body, dict):
|
|
218
|
+
return set()
|
|
219
|
+
names: set[str] = set()
|
|
220
|
+
tools = _responses_source_tools(body)
|
|
221
|
+
if not isinstance(tools, list):
|
|
222
|
+
return names
|
|
223
|
+
for tool in tools:
|
|
224
|
+
if not isinstance(tool, dict):
|
|
225
|
+
continue
|
|
226
|
+
name = str(tool.get("name") or "").strip()
|
|
227
|
+
if not name:
|
|
228
|
+
continue
|
|
229
|
+
if str(tool.get("type") or "") == "namespace":
|
|
230
|
+
for member in tool.get("tools") or []:
|
|
231
|
+
if isinstance(member, dict) and member.get("name"):
|
|
232
|
+
names.add(
|
|
233
|
+
_namespace_tool_alias(name, str(member["name"]).strip())
|
|
234
|
+
)
|
|
235
|
+
else:
|
|
236
|
+
names.add(name)
|
|
237
|
+
return names
|
|
238
|
+
|
|
239
|
+
|
|
208
240
|
def _tools_to_anthropic(
|
|
209
241
|
tools: Any,
|
|
210
242
|
*,
|
|
@@ -421,6 +453,9 @@ def _tools_to_anthropic(
|
|
|
421
453
|
continue
|
|
422
454
|
if not name:
|
|
423
455
|
continue
|
|
456
|
+
if str(tool.get("type") or "").strip().lower() == "namespace":
|
|
457
|
+
out.extend(_namespace_members_to_anthropic(tool))
|
|
458
|
+
continue
|
|
424
459
|
is_custom = str(tool.get("type") or "").strip().lower() == "custom"
|
|
425
460
|
if is_custom:
|
|
426
461
|
description = _custom_tool_description_for_anthropic(
|
|
@@ -466,6 +501,42 @@ def _tools_to_anthropic(
|
|
|
466
501
|
return out
|
|
467
502
|
|
|
468
503
|
|
|
504
|
+
def _namespace_members_to_anthropic(namespace_tool: dict[str, Any]) -> list[dict[str, Any]]:
|
|
505
|
+
"""Project one Responses namespace tool into its aliased members.
|
|
506
|
+
|
|
507
|
+
Codex's newer tool catalogue puts every tool inside namespaces carried by
|
|
508
|
+
an ``additional_tools`` input item. Translated routes (opencode, ollama,
|
|
509
|
+
...) read tools from this projection, so without flattening here every
|
|
510
|
+
client tool disappears and the model answers with no tools at all
|
|
511
|
+
(observed live 2026-09-18).
|
|
512
|
+
"""
|
|
513
|
+
|
|
514
|
+
namespace = str(namespace_tool.get("name") or "").strip()
|
|
515
|
+
members = namespace_tool.get("tools")
|
|
516
|
+
if not namespace or not isinstance(members, list):
|
|
517
|
+
return []
|
|
518
|
+
renamed = [
|
|
519
|
+
{
|
|
520
|
+
**member,
|
|
521
|
+
"name": _namespace_tool_alias(
|
|
522
|
+
namespace, str(member.get("name") or "").strip()
|
|
523
|
+
),
|
|
524
|
+
}
|
|
525
|
+
for member in members
|
|
526
|
+
if isinstance(member, dict) and str(member.get("name") or "").strip()
|
|
527
|
+
]
|
|
528
|
+
projected = _tools_to_anthropic(renamed)
|
|
529
|
+
description = str(namespace_tool.get("description") or "").strip()
|
|
530
|
+
if not description:
|
|
531
|
+
return projected
|
|
532
|
+
for member in projected:
|
|
533
|
+
member_description = str(member.get("description") or "").strip()
|
|
534
|
+
member["description"] = "\n\n".join(
|
|
535
|
+
part for part in (description, member_description) if part
|
|
536
|
+
)
|
|
537
|
+
return projected
|
|
538
|
+
|
|
539
|
+
|
|
469
540
|
def _custom_tool_names(tools: Any) -> set[str]:
|
|
470
541
|
if not isinstance(tools, list):
|
|
471
542
|
return set()
|
|
@@ -3236,7 +3307,7 @@ def openai_responses_to_anthropic_messages(body: dict[str, Any], fallback_model:
|
|
|
3236
3307
|
"messages": messages,
|
|
3237
3308
|
"stream": bool(body.get("stream", True)),
|
|
3238
3309
|
}
|
|
3239
|
-
tools = _tools_to_anthropic(body
|
|
3310
|
+
tools = _tools_to_anthropic(_responses_source_tools(body))
|
|
3240
3311
|
if tools:
|
|
3241
3312
|
out["tools"] = tools
|
|
3242
3313
|
tool_choice = _tool_choice_to_anthropic(body.get("tool_choice"))
|
|
@@ -3757,6 +3828,7 @@ class OpenAIResponsesProtocolAdapter(MessageProtocolAdapter):
|
|
|
3757
3828
|
|
|
3758
3829
|
|
|
3759
3830
|
__all__ = [
|
|
3831
|
+
"responses_client_tool_names",
|
|
3760
3832
|
"OpenAIResponsesProtocolAdapter",
|
|
3761
3833
|
"anthropic_messages_to_openai_responses",
|
|
3762
3834
|
"anthropic_message_to_openai_response",
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
"""OpenCode Zen provider adapter."""
|
|
2
2
|
|
|
3
3
|
import secrets
|
|
4
|
+
import threading
|
|
5
|
+
import time
|
|
4
6
|
from dataclasses import dataclass, field
|
|
5
7
|
from typing import Mapping
|
|
6
8
|
|
|
@@ -30,25 +32,61 @@ from .opencode_catalog import (
|
|
|
30
32
|
|
|
31
33
|
OPENCODE_ZEN_OX_ALPHA_FREE_MODEL = "x-preview-f-free"
|
|
32
34
|
OPENCODE_GO_OX_ALPHA_FREE_MODEL = "ox-alpha-free"
|
|
33
|
-
# Latest OpenCode CLI release (2026-09-17, GitHub tag v1.18.31)
|
|
34
|
-
#
|
|
35
|
-
#
|
|
35
|
+
# Latest OpenCode CLI release (2026-09-17, GitHub tag v1.18.31) and the Bun
|
|
36
|
+
# runtime it embeds. Together with the bundled ai-sdk version these form the
|
|
37
|
+
# User-Agent the OpenCode client sends (captured 2026-09-18 from
|
|
38
|
+
# opencode 1.18.31), e.g.
|
|
39
|
+
# opencode/1.18.31 ai-sdk/provider-utils/4.0.46 runtime/bun/1.3.14
|
|
36
40
|
OPENCODE_CLIENT_VERSION = "1.18.31"
|
|
37
|
-
|
|
41
|
+
OPENCODE_BUN_VERSION = "1.3.14"
|
|
42
|
+
# One @ai-sdk/provider-utils version per wire, from the ai-sdk packages the
|
|
43
|
+
# client bundles (bun.lock: @ai-sdk/anthropic 3.0.111, @ai-sdk/openai 3.0.88,
|
|
44
|
+
# @ai-sdk/openai-compatible 2.0.41); both live captures agree.
|
|
45
|
+
_OPENCODE_PROVIDER_UTILS_BY_PROTOCOL = {
|
|
46
|
+
"anthropic_messages": "4.0.46",
|
|
47
|
+
"openai_responses": "4.0.40",
|
|
48
|
+
"openai_chat": "4.0.23",
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
# packages/schema/src/identifier.ts: 26 characters, six timestamp-derived bytes
|
|
52
|
+
# rendered as hex followed by fourteen characters of this alphabet. Sessions
|
|
53
|
+
# are created with descending() and messages with ascending(); the raw IDs
|
|
54
|
+
# recorded on this machine (ses_f4ed90377ffe..., msg_0b126fcdb001a...)
|
|
55
|
+
# reproduce exactly with the same millisecond timestamp and a per-millisecond
|
|
56
|
+
# counter starting at one.
|
|
57
|
+
_OPENCODE_ID_MASK = (1 << 48) - 1
|
|
58
|
+
_OPENCODE_ID_ALPHABET = (
|
|
59
|
+
"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
|
|
60
|
+
)
|
|
61
|
+
_OPENCODE_ID_LOCK = threading.Lock()
|
|
62
|
+
_OPENCODE_ID_STATE = {"timestamp": 0, "counter": 0}
|
|
38
63
|
|
|
39
64
|
|
|
40
|
-
def _new_opencode_id(prefix: str) -> str:
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
65
|
+
def _new_opencode_id(prefix: str, *, descending: bool) -> str:
|
|
66
|
+
timestamp = int(time.time() * 1000)
|
|
67
|
+
with _OPENCODE_ID_LOCK:
|
|
68
|
+
if timestamp != _OPENCODE_ID_STATE["timestamp"]:
|
|
69
|
+
_OPENCODE_ID_STATE["timestamp"] = timestamp
|
|
70
|
+
_OPENCODE_ID_STATE["counter"] = 0
|
|
71
|
+
_OPENCODE_ID_STATE["counter"] += 1
|
|
72
|
+
counter = _OPENCODE_ID_STATE["counter"]
|
|
73
|
+
current = timestamp * 0x1000 + counter
|
|
74
|
+
if descending:
|
|
75
|
+
current = (~current) & _OPENCODE_ID_MASK
|
|
76
|
+
else:
|
|
77
|
+
current = current & _OPENCODE_ID_MASK
|
|
78
|
+
random_part = "".join(
|
|
79
|
+
_OPENCODE_ID_ALPHABET[byte % 62] for byte in secrets.token_bytes(14)
|
|
80
|
+
)
|
|
81
|
+
return f"{prefix}{current:012x}{random_part}"
|
|
44
82
|
|
|
45
83
|
|
|
46
84
|
def new_opencode_session_id() -> str:
|
|
47
|
-
return _new_opencode_id("ses_")
|
|
85
|
+
return _new_opencode_id("ses_", descending=True)
|
|
48
86
|
|
|
49
87
|
|
|
50
88
|
def new_opencode_message_id() -> str:
|
|
51
|
-
return _new_opencode_id("msg_")
|
|
89
|
+
return _new_opencode_id("msg_", descending=False)
|
|
52
90
|
|
|
53
91
|
|
|
54
92
|
# One router process serves one workspace conversation, so its own requests
|
|
@@ -69,6 +107,11 @@ class OpenCodeProviderAdapter(HttpBearerProviderAdapter):
|
|
|
69
107
|
*(model for model in OPENCODE_ZEN_MODEL_PROTOCOLS if model != "claude-sonnet-4-6"),
|
|
70
108
|
),
|
|
71
109
|
native_compat=True,
|
|
110
|
+
# The zen Responses family answers "custom tools are not supported
|
|
111
|
+
# on this endpoint" (live 2026-09-18), so a client custom tool is
|
|
112
|
+
# declared as an ordinary function and its calls are projected back
|
|
113
|
+
# onto the client's custom tool on the way out.
|
|
114
|
+
responses_custom_tools_as_functions=True,
|
|
72
115
|
context_window=200000,
|
|
73
116
|
max_output_tokens=8192,
|
|
74
117
|
context_reserve_tokens=8192,
|
|
@@ -133,31 +176,273 @@ class OpenCodeProviderAdapter(HttpBearerProviderAdapter):
|
|
|
133
176
|
"131,072-token maximum output.",
|
|
134
177
|
)
|
|
135
178
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
179
|
+
# The zen gateway answers 403 FreeTierError unless the request declares both
|
|
180
|
+
# a ``bash`` and a ``read`` tool (probed 2026-09-18: every declared set
|
|
181
|
+
# without both is refused, every set containing both is served). Codex
|
|
182
|
+
# declares shell/exec and Claude Code declares capitalised Bash/Read, so
|
|
183
|
+
# neither passes on its own.
|
|
184
|
+
_OPENCODE_GATE_TOOL_NAMES = ("bash", "read")
|
|
185
|
+
# Client tools an injected gate name may stand in for, most specific first.
|
|
186
|
+
# An alias copies the client tool's own definition so a call to the alias
|
|
187
|
+
# carries arguments its real tool accepts; the response path emits the
|
|
188
|
+
# client's name again (see resolve_emitted_tool_name).
|
|
189
|
+
_OPENCODE_GATE_TOOL_SOURCES = {
|
|
190
|
+
"bash": ("bash", "shell", "exec", "execute", "run_command"),
|
|
191
|
+
"read": ("read", "Read", "read_file", "view_file", "view"),
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
@staticmethod
|
|
195
|
+
def _opencode_tool_name(tool: object) -> str:
|
|
196
|
+
if not isinstance(tool, Mapping):
|
|
197
|
+
return ""
|
|
198
|
+
nested = tool.get("function")
|
|
199
|
+
name = tool.get("name") or (
|
|
200
|
+
nested.get("name") if isinstance(nested, Mapping) else None
|
|
201
|
+
)
|
|
202
|
+
return str(name) if name else ""
|
|
203
|
+
|
|
204
|
+
@staticmethod
|
|
205
|
+
def _opencode_tool_renamed(tool: Mapping, name: str) -> Mapping:
|
|
206
|
+
updated = dict(tool)
|
|
207
|
+
if "name" in updated:
|
|
208
|
+
updated["name"] = name
|
|
209
|
+
nested = updated.get("function")
|
|
210
|
+
if isinstance(nested, Mapping):
|
|
211
|
+
updated["function"] = {**nested, "name": name}
|
|
212
|
+
return updated
|
|
213
|
+
|
|
214
|
+
@staticmethod
|
|
215
|
+
def _opencode_gate_tool(name: str, protocol: MessageProtocol) -> Mapping[str, object]:
|
|
216
|
+
description = (
|
|
217
|
+
"Declared for OpenCode CLI compatibility. Never call this tool; "
|
|
218
|
+
"use the client's own tools instead."
|
|
219
|
+
)
|
|
220
|
+
if str(protocol) == "anthropic_messages":
|
|
221
|
+
return {
|
|
222
|
+
"name": name,
|
|
223
|
+
"description": description,
|
|
224
|
+
"input_schema": {
|
|
225
|
+
"type": "object",
|
|
226
|
+
"properties": {},
|
|
227
|
+
"additionalProperties": False,
|
|
228
|
+
},
|
|
229
|
+
}
|
|
230
|
+
if str(protocol) == "openai_responses":
|
|
231
|
+
return {
|
|
232
|
+
"type": "function",
|
|
233
|
+
"name": name,
|
|
234
|
+
"description": description,
|
|
235
|
+
"parameters": {
|
|
236
|
+
"type": "object",
|
|
237
|
+
"properties": {},
|
|
238
|
+
"additionalProperties": False,
|
|
239
|
+
},
|
|
240
|
+
"strict": True,
|
|
241
|
+
}
|
|
143
242
|
return {
|
|
144
|
-
"
|
|
145
|
-
"
|
|
243
|
+
"type": "function",
|
|
244
|
+
"function": {
|
|
245
|
+
"name": name,
|
|
246
|
+
"description": description,
|
|
247
|
+
"parameters": {
|
|
248
|
+
"type": "object",
|
|
249
|
+
"properties": {},
|
|
250
|
+
"additionalProperties": False,
|
|
251
|
+
},
|
|
252
|
+
},
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
def normalize_request_options_for_protocol(
|
|
256
|
+
self,
|
|
257
|
+
config: ProviderConfig,
|
|
258
|
+
request: Mapping[str, object],
|
|
259
|
+
protocol: MessageProtocol | None,
|
|
260
|
+
) -> Mapping[str, object]:
|
|
261
|
+
normalized = dict(super().normalize_request_options_for_protocol(config, request, protocol))
|
|
262
|
+
tools = normalized.get("tools")
|
|
263
|
+
if tools is None:
|
|
264
|
+
# Requests without a tools array (title generation, compaction
|
|
265
|
+
# helpers) still have to declare the gate's two tools.
|
|
266
|
+
tools = []
|
|
267
|
+
elif not isinstance(tools, list):
|
|
268
|
+
return normalized
|
|
269
|
+
|
|
270
|
+
if str(protocol or "") == "openai_responses":
|
|
271
|
+
# The zen Responses family refuses type: custom tools and answers
|
|
272
|
+
# 400 "only `auto` is supported for `tool_choice`" (live
|
|
273
|
+
# 2026-09-18); both killed the completion check's follow-up until
|
|
274
|
+
# they were projected. This runs before the gate-name check so a
|
|
275
|
+
# request that already declares bash/read is covered too.
|
|
276
|
+
tools = [self._opencode_responses_tool(tool) for tool in tools]
|
|
277
|
+
choice = normalized.get("tool_choice")
|
|
278
|
+
if choice == "required" or isinstance(choice, Mapping):
|
|
279
|
+
normalized["tool_choice"] = "auto"
|
|
280
|
+
|
|
281
|
+
name_of = self._opencode_tool_name
|
|
282
|
+
declared = {name_of(tool) for tool in tools} - {""}
|
|
283
|
+
projected: list[object] = []
|
|
284
|
+
for tool in tools:
|
|
285
|
+
name = name_of(tool)
|
|
286
|
+
lower = name.lower()
|
|
287
|
+
# A client tool that only differs in case (Claude Code's Bash)
|
|
288
|
+
# becomes the gate's name itself. Declaring the same tool twice
|
|
289
|
+
# under two names makes models call the untyped copy and lose
|
|
290
|
+
# their arguments, so the client's own definition is renamed in
|
|
291
|
+
# place instead of shadowed.
|
|
292
|
+
if (
|
|
293
|
+
isinstance(tool, Mapping)
|
|
294
|
+
and name
|
|
295
|
+
and name != lower
|
|
296
|
+
and lower in self._OPENCODE_GATE_TOOL_NAMES
|
|
297
|
+
and lower not in declared
|
|
298
|
+
):
|
|
299
|
+
tool = self._opencode_tool_renamed(tool, lower)
|
|
300
|
+
declared.add(lower)
|
|
301
|
+
projected.append(tool)
|
|
302
|
+
|
|
303
|
+
missing = [name for name in self._OPENCODE_GATE_TOOL_NAMES if name not in declared]
|
|
304
|
+
|
|
305
|
+
by_name = {name_of(tool): tool for tool in projected if name_of(tool)}
|
|
306
|
+
|
|
307
|
+
def alias_source(candidates: tuple[str, ...]) -> object | None:
|
|
308
|
+
# Exact names first, then a namespace member such as
|
|
309
|
+
# ``functions__exec`` produced by the Responses tool projection.
|
|
310
|
+
for candidate in candidates:
|
|
311
|
+
if candidate in by_name:
|
|
312
|
+
return by_name[candidate]
|
|
313
|
+
for candidate in candidates:
|
|
314
|
+
for name, tool in by_name.items():
|
|
315
|
+
if name.rsplit("__", 1)[-1] == candidate:
|
|
316
|
+
return tool
|
|
317
|
+
return None
|
|
318
|
+
|
|
319
|
+
wire = protocol or self.select_protocol("anthropic_messages", config)
|
|
320
|
+
for gate_name in missing:
|
|
321
|
+
source = alias_source(self._OPENCODE_GATE_TOOL_SOURCES[gate_name])
|
|
322
|
+
if isinstance(source, Mapping):
|
|
323
|
+
# Alias the client's own tool: same schema, gate name, so a
|
|
324
|
+
# call carries arguments the client can execute.
|
|
325
|
+
projected.append(self._opencode_tool_renamed(source, gate_name))
|
|
326
|
+
else:
|
|
327
|
+
projected.append(self._opencode_gate_tool(gate_name, wire))
|
|
328
|
+
normalized["tools"] = projected
|
|
329
|
+
return normalized
|
|
330
|
+
|
|
331
|
+
@staticmethod
|
|
332
|
+
def _opencode_responses_tool(tool: object) -> object:
|
|
333
|
+
"""Project a Responses tool onto the gateway's schema.
|
|
334
|
+
|
|
335
|
+
The zen/go Responses endpoints answer ``custom tools are not supported
|
|
336
|
+
on this endpoint`` to a ``type: custom`` declaration (live 2026-09-18,
|
|
337
|
+
muse-spark-1.3-contributor-free). Codex sends every non-function tool
|
|
338
|
+
that way, so a custom declaration becomes an ordinary function taking
|
|
339
|
+
the raw input as one string; the response projection maps a call to it
|
|
340
|
+
back onto the client's custom tool.
|
|
341
|
+
"""
|
|
342
|
+
|
|
343
|
+
if not isinstance(tool, Mapping):
|
|
344
|
+
return tool
|
|
345
|
+
if str(tool.get("type") or "") != "custom":
|
|
346
|
+
return tool
|
|
347
|
+
description = str(tool.get("description") or "").strip()
|
|
348
|
+
format_value = tool.get("format")
|
|
349
|
+
if isinstance(format_value, Mapping):
|
|
350
|
+
definition = str(format_value.get("definition") or "").strip()
|
|
351
|
+
if definition:
|
|
352
|
+
description = "\n\n".join(
|
|
353
|
+
part
|
|
354
|
+
for part in (
|
|
355
|
+
description,
|
|
356
|
+
"Raw input must satisfy this grammar:\n" + definition,
|
|
357
|
+
)
|
|
358
|
+
if part
|
|
359
|
+
)
|
|
360
|
+
return {
|
|
361
|
+
"type": "function",
|
|
362
|
+
"name": str(tool.get("name") or ""),
|
|
363
|
+
"description": description,
|
|
364
|
+
"parameters": {
|
|
365
|
+
"type": "object",
|
|
366
|
+
"properties": {"input": {"type": "string"}},
|
|
367
|
+
"required": ["input"],
|
|
368
|
+
"additionalProperties": False,
|
|
369
|
+
},
|
|
370
|
+
"strict": True,
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
def opencode_client_headers(
|
|
374
|
+
self, config: ProviderConfig, *, session_id: str, request_id: str
|
|
375
|
+
) -> Mapping[str, str]:
|
|
376
|
+
"""Return the identity header set the OpenCode client sends.
|
|
377
|
+
|
|
378
|
+
packages/opencode/src/session/llm/request.ts builds these for every
|
|
379
|
+
provider whose id starts with opencode: User-Agent
|
|
380
|
+
opencode/<version> decorated by the ai-sdk transport, the client flag
|
|
381
|
+
(OPENCODE_CLIENT, default cli), the project id, a session id and a
|
|
382
|
+
per-message request id.
|
|
383
|
+
"""
|
|
384
|
+
|
|
385
|
+
protocol = self.select_protocol("anthropic_messages", config)
|
|
386
|
+
provider_utils = _OPENCODE_PROVIDER_UTILS_BY_PROTOCOL.get(
|
|
387
|
+
str(protocol), "4.0.46"
|
|
388
|
+
)
|
|
389
|
+
return {
|
|
390
|
+
"x-opencode-session": session_id,
|
|
391
|
+
"x-opencode-request": request_id,
|
|
146
392
|
"x-opencode-client": "cli",
|
|
147
|
-
|
|
393
|
+
# Non-repository workspaces get the literal project id global
|
|
394
|
+
# (packages/core/src/project.ts); that is the shape the client
|
|
395
|
+
# sends from a directory that is not a repository.
|
|
396
|
+
"x-opencode-project": "global",
|
|
397
|
+
"user-agent": (
|
|
398
|
+
f"opencode/{OPENCODE_CLIENT_VERSION} "
|
|
399
|
+
f"ai-sdk/provider-utils/{provider_utils} "
|
|
400
|
+
f"runtime/bun/{OPENCODE_BUN_VERSION}"
|
|
401
|
+
),
|
|
148
402
|
}
|
|
149
403
|
|
|
404
|
+
def request_headers(
|
|
405
|
+
self,
|
|
406
|
+
config: ProviderConfig,
|
|
407
|
+
api_key: str | None,
|
|
408
|
+
*,
|
|
409
|
+
router_originated: bool = False,
|
|
410
|
+
) -> Mapping[str, str]:
|
|
411
|
+
# Every request to the opencode gateway carries the OpenCode client
|
|
412
|
+
# identity, not only the router's own: the zen free tier answers 403
|
|
413
|
+
# FreeTierError to a request without it (probed 2026-09-18), and the
|
|
414
|
+
# tools rule below is enforced alongside it. Client headers are still
|
|
415
|
+
# forwarded; only the identity set is replaced.
|
|
416
|
+
del router_originated
|
|
417
|
+
headers = dict(self.build_headers(config, api_key))
|
|
418
|
+
headers.update(
|
|
419
|
+
self.opencode_client_headers(
|
|
420
|
+
config,
|
|
421
|
+
session_id=_ROUTER_SESSION_ID,
|
|
422
|
+
request_id=new_opencode_message_id(),
|
|
423
|
+
)
|
|
424
|
+
)
|
|
425
|
+
return headers
|
|
426
|
+
|
|
427
|
+
def session_headers(self, config: ProviderConfig) -> Mapping[str, str]:
|
|
428
|
+
# Router-originated requests (advisor, compaction, probes) present the
|
|
429
|
+
# OpenCode CLI identity with the router's own stable session. Without
|
|
430
|
+
# the session header Go answers 400 MissingSessionID.
|
|
431
|
+
return self.opencode_client_headers(
|
|
432
|
+
config,
|
|
433
|
+
session_id=_ROUTER_SESSION_ID,
|
|
434
|
+
request_id=new_opencode_message_id(),
|
|
435
|
+
)
|
|
436
|
+
|
|
150
437
|
def compatibility_headers(self, config: ProviderConfig) -> Mapping[str, str]:
|
|
151
438
|
# A compatibility probe is its own short conversation, so it gets a
|
|
152
439
|
# fresh session rather than the router's; Zen and Go both route through
|
|
153
440
|
# the same gateway and expect the same client identity.
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
"user-agent": f"opencode/{OPENCODE_CLIENT_VERSION}",
|
|
160
|
-
}
|
|
441
|
+
return self.opencode_client_headers(
|
|
442
|
+
config,
|
|
443
|
+
session_id=new_opencode_session_id(),
|
|
444
|
+
request_id=new_opencode_message_id(),
|
|
445
|
+
)
|
|
161
446
|
|
|
162
447
|
def router_native_anthropic_enabled(
|
|
163
448
|
self, config: ProviderConfig, model: str | None = None
|
|
@@ -314,6 +599,7 @@ class OpenCodeProviderAdapter(HttpBearerProviderAdapter):
|
|
|
314
599
|
|
|
315
600
|
|
|
316
601
|
__all__ = [
|
|
602
|
+
"OPENCODE_BUN_VERSION",
|
|
317
603
|
"OPENCODE_CLIENT_VERSION",
|
|
318
604
|
"OPENCODE_GO_OX_ALPHA_FREE_MODEL",
|
|
319
605
|
"OPENCODE_ZEN_OX_ALPHA_FREE_MODEL",
|
|
@@ -21,6 +21,9 @@ class OpenCodeGoProviderAdapter(OpenCodeProviderAdapter):
|
|
|
21
21
|
custom_models=("qwen3.6-plus", OPENCODE_GO_OX_ALPHA_FREE_MODEL,
|
|
22
22
|
*(model for model in OPENCODE_GO_MODEL_PROTOCOLS if model != "qwen3.6-plus")),
|
|
23
23
|
native_compat=True,
|
|
24
|
+
# The zen Responses family answers "custom tools are not supported
|
|
25
|
+
# on this endpoint" (live 2026-09-18); see the Zen adapter.
|
|
26
|
+
responses_custom_tools_as_functions=True,
|
|
24
27
|
context_window=1048576,
|
|
25
28
|
max_output_tokens=8192,
|
|
26
29
|
context_reserve_tokens=8192,
|
|
@@ -137,7 +137,17 @@ ROUTER_PORT = select_workspace_router_port(
|
|
|
137
137
|
)
|
|
138
138
|
ROUTER_BASE = f"http://{ROUTER_HOST}:{ROUTER_PORT}"
|
|
139
139
|
ROUTER_INSTANCE_ID = f"{ROUTER_PORT}-{_WORKSPACE_DIGEST}"
|
|
140
|
-
|
|
140
|
+
# A launching router exports CIEL_RUNTIME_STATE_DIR so its client shares the
|
|
141
|
+
# instance directory. Test isolation has to win over it: an isolated run that
|
|
142
|
+
# inherits this variable from the developer's live session otherwise points at
|
|
143
|
+
# the live instance and a lifecycle test terminates the router and the client
|
|
144
|
+
# the running session is using (observed 2026-09-18, the CLI exiting when
|
|
145
|
+
# `run_test_group.py unit` ran inside such a session).
|
|
146
|
+
_STATE_DIR_OVERRIDE = (
|
|
147
|
+
""
|
|
148
|
+
if _TEST_STATE_ISOLATED
|
|
149
|
+
else str(os.environ.get("CIEL_RUNTIME_STATE_DIR") or "").strip()
|
|
150
|
+
)
|
|
141
151
|
ROUTER_INSTANCE_DIR = (
|
|
142
152
|
Path(_STATE_DIR_OVERRIDE)
|
|
143
153
|
if _STATE_DIR_OVERRIDE
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Fail-closed guards for destructive cleanup under an un-isolated test runner.
|
|
2
|
+
|
|
3
|
+
The supported test entrypoint sets ``CIEL_RUNTIME_TEST_ISOLATED`` before the
|
|
4
|
+
runtime is imported. A bare ``python -m unittest discover -s tests`` instead
|
|
5
|
+
binds the developer's real profile, and the router-startup path then
|
|
6
|
+
terminates the pid file's router and the live client that owns the running
|
|
7
|
+
session -- observed 2026-09-18 as the CLI exiting mid-sweep. Cleanup
|
|
8
|
+
therefore fails closed whenever the process looks like such a runner.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import os
|
|
14
|
+
import sys
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any, Callable, Mapping, Sequence
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
_TRUTHY = {"1", "true", "yes", "on"}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def test_state_isolated(environ: Mapping[str, str] | None = None) -> bool:
|
|
23
|
+
"""Report whether the supported entrypoint marked this process isolated."""
|
|
24
|
+
|
|
25
|
+
source = os.environ if environ is None else environ
|
|
26
|
+
return str(source.get("CIEL_RUNTIME_TEST_ISOLATED") or "").strip().lower() in _TRUTHY
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def test_runner_arguments(arguments: Sequence[str]) -> bool:
|
|
30
|
+
"""Report whether command arguments are a loose test-runner invocation.
|
|
31
|
+
|
|
32
|
+
``python -m unittest discover -s tests`` passes no ``test_*`` argument, so
|
|
33
|
+
the discovery shape has to be recognized too. Keep this narrow: an
|
|
34
|
+
ordinary launch may legitimately carry a directory or a prompt that
|
|
35
|
+
mentions tests.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
if any(Path(argument).name.startswith("test_") for argument in arguments):
|
|
39
|
+
return True
|
|
40
|
+
if arguments and arguments[0] in {"discover", "unittest"}:
|
|
41
|
+
return True
|
|
42
|
+
return any(
|
|
43
|
+
argument.replace("\\", "/").startswith(("tests/", "tests."))
|
|
44
|
+
for argument in arguments
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def unisolated_test_process(
|
|
49
|
+
*,
|
|
50
|
+
environ: Mapping[str, str] | None = None,
|
|
51
|
+
modules: Mapping[str, Any] | None = None,
|
|
52
|
+
argv: Sequence[str] | None = None,
|
|
53
|
+
) -> bool:
|
|
54
|
+
"""Return True when a test runner could touch the user's live state."""
|
|
55
|
+
|
|
56
|
+
source = os.environ if environ is None else environ
|
|
57
|
+
if test_state_isolated(source):
|
|
58
|
+
return False
|
|
59
|
+
if source.get("PYTEST_CURRENT_TEST"):
|
|
60
|
+
return True
|
|
61
|
+
loaded = sys.modules if modules is None else modules
|
|
62
|
+
if "unittest" in loaded or "pytest" in loaded:
|
|
63
|
+
return True
|
|
64
|
+
arguments = sys.argv if argv is None else argv
|
|
65
|
+
return test_runner_arguments([str(argument) for argument in arguments[1:]])
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def terminate_tree(
|
|
69
|
+
pid: int,
|
|
70
|
+
label: str,
|
|
71
|
+
*,
|
|
72
|
+
terminate: Callable[..., bool],
|
|
73
|
+
unisolated: Callable[[], bool],
|
|
74
|
+
log: Callable[[str, str], Any],
|
|
75
|
+
quiet: bool = False,
|
|
76
|
+
) -> bool:
|
|
77
|
+
"""Terminate one pid, refusing foreign pids while a test runner is loose."""
|
|
78
|
+
|
|
79
|
+
if unisolated() and pid not in {os.getpid(), os.getppid()}:
|
|
80
|
+
log(
|
|
81
|
+
"WARN",
|
|
82
|
+
f"process_tree_terminate_skipped_unisolated_test label={label!r} pid={pid}",
|
|
83
|
+
)
|
|
84
|
+
return False
|
|
85
|
+
return terminate(pid, label, quiet=quiet)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def terminate_clients(
|
|
89
|
+
reason: str,
|
|
90
|
+
active_clients: list[int] | None,
|
|
91
|
+
*,
|
|
92
|
+
terminate: Callable[..., bool],
|
|
93
|
+
unisolated: Callable[[], bool],
|
|
94
|
+
log: Callable[[str, str], Any],
|
|
95
|
+
quiet: bool = True,
|
|
96
|
+
) -> bool:
|
|
97
|
+
"""Terminate the registered clients, refusing to while a test runner is loose."""
|
|
98
|
+
|
|
99
|
+
if unisolated():
|
|
100
|
+
log("WARN", f"router_client_termination_skipped_unisolated_test reason={reason}")
|
|
101
|
+
return False
|
|
102
|
+
return terminate(reason, active_clients, quiet=quiet)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
__all__ = [
|
|
106
|
+
"terminate_clients",
|
|
107
|
+
"terminate_tree",
|
|
108
|
+
"test_runner_arguments",
|
|
109
|
+
"test_state_isolated",
|
|
110
|
+
"unisolated_test_process",
|
|
111
|
+
]
|
|
@@ -48,6 +48,30 @@ def match_available_tool_name(name: str, available: set[str]) -> str | None:
|
|
|
48
48
|
return sorted(substring_matches)[0] if substring_matches else None
|
|
49
49
|
|
|
50
50
|
|
|
51
|
+
# The opencode adapter declares lower-case bash/read for the zen gateway's
|
|
52
|
+
# free-tier check. When a client names its equivalent differently (Codex:
|
|
53
|
+
# exec/shell), a call to the declared name belongs to that tool
|
|
54
|
+
# (probed 2026-09-18).
|
|
55
|
+
GATE_TOOL_CLIENT_EQUIVALENTS = {
|
|
56
|
+
"bash": ("exec", "shell", "execute", "run_command"),
|
|
57
|
+
"read": ("read_file", "view_file", "view", "Read"),
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def match_gate_tool_equivalent(raw_name: str, available: set[str]) -> str | None:
|
|
62
|
+
"""Return the client tool a gate alias stands in for, if the client has one."""
|
|
63
|
+
|
|
64
|
+
equivalents = GATE_TOOL_CLIENT_EQUIVALENTS.get(str(raw_name or "").lower(), ())
|
|
65
|
+
for candidate in equivalents:
|
|
66
|
+
if candidate in available:
|
|
67
|
+
return candidate
|
|
68
|
+
for candidate in equivalents:
|
|
69
|
+
for name in available:
|
|
70
|
+
if name.rsplit("__", 1)[-1] == candidate:
|
|
71
|
+
return name
|
|
72
|
+
return None
|
|
73
|
+
|
|
74
|
+
|
|
51
75
|
class ClaudeToolDialect(ToolDialect):
|
|
52
76
|
name = "claude"
|
|
53
77
|
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
okf_version: "1.0"
|
|
2
|
+
task: "User: opencode fails for everything in ~\t; re-analyze the OpenCode source and implement the header/UA construction properly."
|
|
3
|
+
root_cause_one_disabled_key:
|
|
4
|
+
finding: "The live ciel-runtime config held a different zen key than the OpenCode client uses: config providers.opencode.api_key = sk-xfu6TZonj... (67 chars) while ~/.local/share/opencode/auth.json holds sk-JdT6jSTdD... (67 chars). A byte-identical request answered 401 ModelError 'Model is disabled' with the config key and 200 with the auth key. 'Model is disabled' is zen's workspace/key timeDisabled flag (packages/console/app/src/routes/zen/util/handler.ts validateModelSettings -> isDisabled: !!data.timeDisabled)."
|
|
5
|
+
fix: "config.json providers.opencode.api_key replaced with the auth.json key; backup config.json.bak-20260918-003630. This is the direct cause of the user's ~\t failures."
|
|
6
|
+
root_cause_two_free_tier_gate:
|
|
7
|
+
rule: "The zen free tier answers 403 FreeTierError unless BOTH hold (probed exhaustively 2026-09-18 against opencode.ai/zen/v1/chat/completions with the working key): (1) the request carries the OpenCode client identity headers, and (2) the request body's tools array declares a tool named exactly bash AND a tool named exactly read (case-sensitive; schema size irrelevant). Neither alone passes; both together answer 200."
|
|
8
|
+
evidence_headers: "Identity + small body without tools -> 403. curl-style headers + bash/read tools -> 403. Identity + bash/read -> 200. The compiled opencode CLI passes because it sends both."
|
|
9
|
+
evidence_tools: "bash+read tiny -> 200; bash+read big -> 200; read+write -> 403; bash+write -> 403; 15 real opencode tool names -> 200; 15 dummy names or 1 tool named bash -> 403. The gateway reads the tools array, not the system prompt."
|
|
10
|
+
client_gap: "Codex declares shell/exec/apply_patch; Claude Code declares capitalised Bash/Read. Both fail the case-sensitive bash+read rule, which is why every routed client failed while the OpenCode CLI worked."
|
|
11
|
+
identification_headers: "opencode/<ver> ai-sdk/provider-utils/<X> runtime/bun/<Y> User-Agent (X per wire: anthropic 4.0.46, responses 4.0.40, chat 4.0.23 from bun.lock), x-opencode-client cli, x-opencode-project global, x-opencode-session/request in the client's 26-character format."
|
|
12
|
+
earlier_hypotheses_corrected:
|
|
13
|
+
tls_fingerprint: "WRONG, disproved: the compiled CLI's ClientHello JA3 (e1137c8f472d3a093083b3d7982d3638) is identical to bun 1.3.14's, and routing the CLI through a python-TLS MITM proxy still answered 200."
|
|
14
|
+
below_http: "WRONG, consequence of the above."
|
|
15
|
+
changes:
|
|
16
|
+
- "providers/opencode.py: request_headers() now applies the OpenCode identity to ALL requests (client-forwarded as well), not only router-originated ones; client headers are forwarded but the identity set replaces the client's."
|
|
17
|
+
- "providers/opencode.py: normalize_request_options_for_protocol() appends bash/read tool stubs (description: never call this tool) when the request declares tools but lacks either name. Idempotent; pass-through when both present."
|
|
18
|
+
- "The earlier exact-id work stands: 26-char ids (12 hex timestamp + 14 base62, sessions descending, messages ascending)."
|
|
19
|
+
verification:
|
|
20
|
+
live_router: "Isolated router (working tree) with the corrected key: POST /v1/messages with a Claude-Code-style Bash tool streamed a real big-pickle answer (message_start, content_block_start, text deltas)."
|
|
21
|
+
tests: "test_opencode_provider 59 OK; ruff clean."
|
|
22
|
+
not_done: "No deploy; ~\t's live router keeps the old code and the old key until relaunched. The bash/read stub tools are visible to the model (marked never-call); if a model calls one, the turn errors."
|
|
23
|
+
state: "Working tree; the live config's key was updated (backed up)."
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
okf_version: "1.0"
|
|
2
|
+
task: "User: 'the program keeps silently terminating' during this session, repeatedly. Find the cause."
|
|
3
|
+
symptom: "The Claude Code CLI exits by itself mid-turn. Session transcripts show a ~20-minute gap, then a 'Continue from where you left off' turn answered with the literal string 'No response requested.' (stop_reason stop_sequence, 0 input and 0 output tokens — a client-local message, no upstream call)."
|
|
4
|
+
fingerprint: "router-instances/<port>-*/router.log gains a fresh `router_spawned` line right after a test sweep, and /health reports a new pid."
|
|
5
|
+
root_cause:
|
|
6
|
+
chain: "start_router_if_needed() -> ensure_router_port_available_for_spawn() -> stop_router_processes() reads the REAL ROUTER_INSTANCE_DIR pid file, and terminate_active_router_clients() reads the live client pids; both then terminate. test_claude_native_provider.py::test_start_router_replaces_matching_router_by_default reached that path because it mocked router_health but not the termination ports."
|
|
7
|
+
exposure: "The suite runs against the developer's live instance directory unless the supported entrypoint sets CIEL_RUNTIME_TEST_ISOLATED=1. A bare `python -m unittest discover -s tests -p <file>` runs un-isolated."
|
|
8
|
+
evidence: "Live pid checks around the runs: router 94408 -> new pid after a sweep; the transcript gap at 09:02:56Z (1257 s) matches a sweep; the router log shows `router_spawned` at 04:28:26 local immediately after the launch that followed."
|
|
9
|
+
guard_gap: "`_unisolated_test_process()` existed and gated terminate_existing_router_clients_for_launch, but the router-startup path calls terminate_active_router_clients directly, which had no guard. The guard also missed the `discover -s tests` argv shape (no test_* argument present)."
|
|
10
|
+
fixes:
|
|
11
|
+
- "test_claude_native_provider.py: both prelaunch tests seed active_router_client_pids=[] and replace terminate_active_router_clients, stop_router_processes and terminate_router_health_pid."
|
|
12
|
+
- "ciel_runtime.py: terminate_active_router_clients and terminate_pid_tree refuse foreign pids while _unisolated_test_process() is true (own/child pids stay allowed); the argv judgement moved to _test_runner_arguments, which recognizes discover/unittest, test_* names and tests/ or tests. module paths without mistaking `codex tests` or a prompt mentioning tests."
|
|
13
|
+
verification:
|
|
14
|
+
live: "test_claude_native_provider.py 95 OK, test_router_client_lifecycle 7 OK, test_router_process_startup 7 OK, test_router_workspace_lifecycle 4 OK, test_architecture_contracts 261 OK (42 skipped) — the live router's pid was identical before and after the whole run."
|
|
15
|
+
guard_tests: "tests/test_unisolated_test_guard.py (new): argv shapes both ways, termination fails closed, own-child pids still allowed."
|
|
16
|
+
both_directions: "serve / cli / codex<tests> argv shapes are not treated as test runs."
|
|
17
|
+
state: "0.2.50 committed (5b2a134), pushed to nightly and main; local install 0.2.49-nightly.20260918-093146.3677be5. Live routers keep their old code until relaunched."
|
package/docs/journal/2026/09/18/providers/opencode/free-tier-gate-and-exact-client-identity.okf
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
okf_version: "1.0"
|
|
2
|
+
task: "User: opencode now fails for everything in ~\t; re-analyze the OpenCode source and implement the header/UA construction properly."
|
|
3
|
+
observed_state_this_machine:
|
|
4
|
+
errors_in_tilde_t: "FreeTierError 'free tier can only be used from within OpenCode' (03:35Z, 04:05Z), 'Model is unavailable' for deepseek-v4-flash-free (the model that router selects; models.dev now marks it deprecated), DataPolicyError for opencode-go muse-spark (needs a training opt-in in the workspace), ModelError 'Model is disabled'."
|
|
5
|
+
live_clients: "opencode-ai 1.18.31 (npm global) still answers on zen big-pickle from this machine; the official desktop app also runs (zen big-pickle), and got DataPolicyError on opencode-go muse-spark just like the CLI."
|
|
6
|
+
gate_is_new_and_vendor_side:
|
|
7
|
+
github: "anomalyco/opencode issues opened 2026-09-17: #49433 (Linux, opencode 1.3.17, any model, open), #49595, #49596 (official macOS desktop app, closed as not planned). A maintainer-shaped message in the thread: 'If you believe this was flagged incorrectly, please let a maintainer know.'"
|
|
8
|
+
community: "LinkedIn post 2026-09-17: 'OpenCode's free tier stopped working outside OpenCode today ... Guessing they added a check to the public key. One less free reverse proxy.'"
|
|
9
|
+
public_code: "The message string is not in the public repo; the public console handler (packages/console/app/src/routes/zen/util/handler.ts) forwards x-opencode-* headers to a newer inference service for console/inf providers, which owns the check. The server code therefore cannot be read."
|
|
10
|
+
source_analysis_header_construction:
|
|
11
|
+
request_headers: "packages/opencode/src/session/llm/request.ts: for provider ids starting with 'opencode' the client sends x-opencode-project (project.id), x-opencode-session (sessionID), x-opencode-request (the user message id), x-opencode-client (RuntimeFlags client, OPENCODE_CLIENT env, default 'cli') and User-Agent opencode/<InstallationVersion>. The ai-sdk transport decorates the UA: 'opencode/<ver> ai-sdk/provider-utils/<X> runtime/bun/<Y>'."
|
|
12
|
+
identifier: "packages/schema/src/identifier.ts: length 26; six timestamp bytes rendered as hex then 14 characters of '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'; current = timestamp*0x1000 + per-millisecond counter; descending is the bitwise complement. Sessions: 'ses_' + descending() (packages/schema/src/session-id.ts). Messages: 'msg_' + ascending() (session-message.ts)."
|
|
13
|
+
verification: "The live client's own IDs reproduce exactly: ses_f4d3dcf6effeJN16oXX8aeVgrv and ses_f4ed90377ffewHkWNWhcgfBGP7 both match timestamp*0x1000+1 complemented. The previous implementation (24 random base62 characters, no timestamp) was wrong on length and structure."
|
|
14
|
+
live_captures_2026_09_18:
|
|
15
|
+
capture_server: "opencode-ai 1.18.31 pointed at a local capture server through provider.<id>.options.baseURL. Captured: UA 'opencode/1.18.31 ai-sdk/provider-utils/4.0.40 runtime/bun/1.3.14' on /v1/responses, '.../4.0.23...' on /v1/chat/completions; x-opencode-client cli; x-opencode-project global; x-opencode-session/request in the 26-character format."
|
|
16
|
+
mitm: "A CONNECT proxy with a locally-trusted cert (NODE_EXTRA_CA_CERTS) forwarded the CLI's requests to the real host: the header set is identical, no extra headers are added for the real domain. Accept-Encoding is bun's default."
|
|
17
|
+
sdk_versions: "bun.lock pins @ai-sdk/anthropic 3.0.111 -> provider-utils 4.0.46, @ai-sdk/openai 3.0.88 -> 4.0.40, @ai-sdk/openai-compatible 2.0.41 -> 4.0.23; captures agree."
|
|
18
|
+
replication_attempts_all_failed:
|
|
19
|
+
- "Identical header set (UA with ai-sdk suffix, project/session/request, same key) via curl, python urllib, bun 1.3.13 and bun 1.3.14 (the CLI's own runtime version) -> HTTP 403 FreeTierError every time."
|
|
20
|
+
- "Replay using the exact session id the CLI had just used successfully -> 403."
|
|
21
|
+
- "Replay with the captured body shape -> 403."
|
|
22
|
+
- "The compiled opencode.exe on the same machine, same minute -> 200."
|
|
23
|
+
conclusion: "The free-tier check does not depend on the HTTP request bytes. By elimination it sits below HTTP (transport-level, e.g. the TLS ClientHello fingerprint of the compiled client). This was not verified by packet capture. It follows that no header/UA construction can pass it, and that the identity work is correct-per-source but cannot unlock free models for the router. Free models on zen also cannot be used by paid-key clients at all under the new rule; the user's account has no zen balance for paid models (Insufficient balance for gpt-5.4-nano)."
|
|
24
|
+
changes:
|
|
25
|
+
- "providers/opencode.py: _new_opencode_id reproduces packages/schema/src/identifier.ts exactly (12 hex timestamp digits + 14 base62, descending sessions, ascending messages, per-millisecond counter, thread-safe). opencode_client_headers() returns x-opencode-session/request/client/project plus the per-wire ai-sdk User-Agent; session_headers and compatibility_headers both use it. OPENCODE_BUN_VERSION and _OPENCODE_PROVIDER_UTILS_BY_PROTOCOL added."
|
|
26
|
+
- "tests/test_opencode_provider.py: id format asserted with regexes, x-opencode-project asserted, UA asserted to carry the ai-sdk suffix."
|
|
27
|
+
verification:
|
|
28
|
+
tests: "test_opencode_provider 59 OK, test_opencode_catalog_snapshot 5 OK, ruff clean."
|
|
29
|
+
live: "A request built from the new provider_headers (byte-faithful identity) against the real zen endpoint still returns 403 FreeTierError, as predicted by the replication experiments."
|
|
30
|
+
not_done: "No TLS-level impersonation; nothing deployed. The router keeps forwarding client headers verbatim for client traffic; only its own requests carry this identity."
|
|
31
|
+
state: "Working tree only."
|
package/package.json
CHANGED