Skip to content
Merged
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
# WP1 — types.ts value-leaf extraction (stacked PR 1 of the split program)

Unit: devlog/_plan/260818_megafile_split_program. Risk basis: 000_risk_assessment.md.
Branch: codex/split-wp1-types on dev @ b04cd26e7 (post FastWire B0/B1 merge).
Class: C2 (mechanical move, shared-runtime file, full-suite gate).

## Loop spec

- Archetype: pure-move refactor, zero behavior change.
- Trigger: split program WP1, lowest-risk opener.
- Goal: src/types.ts stops carrying runtime value code; values live in leaves;
every existing import keeps working via re-export.
- Non-goals: NO type-cluster split yet (OcxConfig/OcxProviderConfig stay),
NO consumer retargeting to leaf paths, NO behavior or signature change.
- Verifier: bun run typecheck && bun run test (full — shared runtime file).
- Stop: both green + core-lab-boundary green; PR opened against dev.
- Memory artifact: this doc + ledger attests.

## Scope (IN)

Extract the two VALUE clusters from src/types.ts (1867 lines) into leaves:

1. src/types/tools.ts — lines ~236-292:
namespacedToolName, toolChoiceAliases, toolAllowedByChoice,
resolveToolChoiceWireName, modelInList, OcxToolChoice (type),
isAllowedToolChoice, toolChoiceToolPredicate.
Needs `import type { OcxTool } from "../types"` — type-only, erased at
runtime, so the types.ts -> tools.ts re-export is NOT a runtime cycle.
2. src/types/wire.ts — lines ~1760-1839:
UPSTREAM_HTTP_VERSION_VALUES, UpstreamHttpVersion,
REASONING_SUMMARY_DELIVERY_VALUES, ReasoningSummaryDelivery,
CodexAccountMode, OPENAI_PROVIDER_TIER_VERSION,
MODEL_ADAPTER_OVERRIDE_ALLOWED, ANTHROPIC_WIRE_MODELS (internal),
anthropicWireModelsForProvider (internal), captureWireAdapterHardPins,
isWirePinnedModel, pinnedWireAdapter. Self-contained, no imports.

src/types.ts keeps every current export via `export ... from "./types/..."`;
type-only names re-exported with `export type`.

## Scope (OUT)

- All interface/type clusters stay in types.ts this PR.
- No import-path changes anywhere else in src/ or tests/.
- No lab imports anywhere new (types is on the protected graph as a value
import from responses/core.ts: modelInList, namespacedToolName).

## File change map

- ADD src/types/tools.ts (~60 lines incl. docs)
- ADD src/types/wire.ts (~85 lines incl. docs)
- EDIT src/types.ts: delete moved bodies, add two re-export blocks at the
same positions; net -120 lines.

## Accept criteria

1. bun run typecheck exit 0.
2. bun run test full suite: same pass count as base (13k+), 0 fail.
3. tests/core-lab-boundary.test.ts green (covers the new static edges
types.ts -> types/tools.ts, types/wire.ts on the protected walk).
4. rg confirms no consumer file changed: git diff --stat touches exactly 3
files.
5. Value identity preserved: MODEL_ADAPTER_OVERRIDE_ALLOWED still a single
ReadonlySet instance (only one declaration site, re-export not re-create).

Activation grounding: criterion 3's scenario is the existing boundary test
run; criterion 5's scenario is the full suite (service-tier tests compare
set membership through both import paths).

## Verifier reality (PLAN-VERIFIER-REAL-01)

- bun run typecheck: exists in package.json, reads src/ via tsconfig
include ["src"] — observes both new files. To be run in C.
- bun run test: tests/ suite imports ../src/types in 400 files — observes
the barrel; core-lab-boundary walks the import graph from the three
protected roots which reach types.ts — observes the new edges.

## Stacked-PR plan (DEV-STACK-01)

PR 1 (this): value leaves + barrel. Target: dev.
PR 2 (next cycle): type-cluster split (request/config/provider/accounts)
stacked on PR 1's head branch.
Later cycles per 000_risk_assessment.md order (config leaves, registry, ...).

## Audit amendments (A-phase, 2 auditors: grok-4.6 NEAR-PASS / gpt-5.6-sol FAIL->fixed)

1. CYCLE FIX (sol blocker): OcxTool (lines 211-232) moves INTO types/tools.ts.
tools.ts imports NOTHING from ../types — dependency is strictly one-way
(types.ts -> types/tools.ts). types.ts re-exports OcxTool as a type.
2. RECIPE FIX (grok finding 7): `export type { X } from` does not BIND X in
the barrel. types.ts still uses OcxTool (line 106), OcxToolChoice (299),
UpstreamHttpVersion (1455), CodexAccountMode (1470),
ReasoningSummaryDelivery (1574) — so the barrel adds a local
`import type { OcxTool, OcxToolChoice } from "./types/tools"` and
`import type { UpstreamHttpVersion, ReasoningSummaryDelivery,
CodexAccountMode } from "./types/wire"` next to the Kiro import.
3. OcxToolChoice + its guards travel with tools.ts (they are one cluster).
4. Extensionless specifiers only (lab walker resolves `${base}.ts`).
5. AC4 corrected: scope proof = `git diff --stat <base>..HEAD -- src tests`
showing exactly 3 src files; devlog/plan files are committed separately.
6. AC5 proof corrected: identity is preserved by ESM re-export semantics
(single declaration site); drop the false 'both import paths' claim.
7. Protected-roots note corrected: PROTECTED has 4 files; only
responses/core.ts puts types.ts on the runtime graph (core.ts:63).

234 changes: 23 additions & 211 deletions src/types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import type { KiroOAuthMetadata } from "./oauth/types";
import type { OcxTool, OcxToolChoice } from "./types/tools";
import type { UpstreamHttpVersion, ReasoningSummaryDelivery, CodexAccountMode } from "./types/wire";

/** Exact provider/credential namespace for process-local reasoning replay. */
export interface OcxReasoningReplayIdentity {
Expand Down Expand Up @@ -214,137 +216,17 @@ export interface OcxProviderOpaqueToolCallMetadata {

export type OcxAssistantContentPart = OcxTextContent | OcxThinkingContent | OcxToolCall;

export interface OcxTool {
name: string;
description: string;
parameters: Record<string, unknown>;
strict?: boolean;
/** MCP namespace (e.g. "mcp__context7") for tools flattened out of a Responses "namespace" tool. */
namespace?: string;
/** Freeform/custom tool (e.g. apply_patch): the model's call must be relayed as a custom_tool_call. */
freeform?: boolean;
/** Client-executed tool discovery (tool_search): the model's call must be relayed as a tool_search_call. */
toolSearch?: boolean;
/** Tool definition restored from a prior tool_search output; transports may prioritize it when catalogs are bounded. */
loadedFromToolSearch?: boolean;
/** Cursor-only synthetic exact-match edit tool; never inferred from the wire name. */
cursorStructuredEdit?: true;
/** Synthetic web_search tool: the model's call is executed by the gpt-5.4-mini sidecar, not relayed to Codex. */
webSearch?: boolean;
/** Synthetic image_gen tool: the model's call is executed by the xAI image bridge sidecar, not relayed to Codex. */
imageGeneration?: boolean;
/** Synthetic video_gen tool: executed by the xAI video bridge sidecar. */
videoGeneration?: boolean;
}

/**
* Wire name a chat model sees for a tool. Namespaced (MCP) tools are flattened to
* "<namespace>__<name>" so they survive the chat-completions function-tool format;
* the proxy maps this back to {namespace, name} on the return trip (Codex routes MCP
* calls by an explicit `namespace` field, not by parsing the name).
*/
export function namespacedToolName(namespace: string | undefined, name: string): string {
return namespace ? `${namespace}__${name}` : name;
}

export function toolChoiceAliases(tool: Pick<OcxTool, "namespace" | "name">): string[] {
const wireName = namespacedToolName(tool.namespace, tool.name);
return tool.namespace ? [wireName, `${tool.namespace}.${tool.name}`] : [wireName];
}

function sameToolIdentity(
left: Pick<OcxTool, "namespace" | "name">,
right: Pick<OcxTool, "namespace" | "name">,
): boolean {
return left.namespace === right.namespace && left.name === right.name;
}

/**
* All tools that could be selected by one client-facing name. Bare logical names are included
* here because they are a compatibility selector for namespaced tools, while wire and dotted
* aliases come from `toolChoiceAliases`. A selector with more than one candidate is invalid.
*/
export function toolChoiceCandidates(
tools: readonly Pick<OcxTool, "namespace" | "name">[] | undefined,
name: string,
): Pick<OcxTool, "namespace" | "name">[] {
if (!tools) return [];
const candidates: Pick<OcxTool, "namespace" | "name">[] = [];
for (const tool of tools) {
if (tool.name !== name && !toolChoiceAliases(tool).includes(name)) continue;
if (!candidates.some(candidate => sameToolIdentity(candidate, tool))) candidates.push(tool);
}
return candidates;
}

/**
* Newer Codex clients can select a tool nested in a namespace by its bare name. Resolve that
* shorthand only when the request contains one tool with the logical name, so an ambiguous name
* cannot authorize a tool from an unintended namespace.
*/
export function toolAllowedByChoice(
tool: Pick<OcxTool, "namespace" | "name">,
allowedTools: ReadonlySet<string>,
tools?: readonly Pick<OcxTool, "namespace" | "name">[],
): boolean {
if (!tools) return toolChoiceAliases(tool).some(name => allowedTools.has(name));
for (const name of [...toolChoiceAliases(tool), tool.name]) {
if (!allowedTools.has(name)) continue;
const candidates = toolChoiceCandidates(tools, name);
if (candidates.length === 1 && sameToolIdentity(candidates[0], tool)) return true;
}
return false;
}

export function resolveToolChoiceWireName(tools: readonly Pick<OcxTool, "namespace" | "name">[] | undefined, name: string): string {
const candidates = toolChoiceCandidates(tools, name);
if (candidates.length === 1) {
const match = candidates[0];
return namespacedToolName(match.namespace, match.name);
}
// Keep unknown/ambiguous names unchanged for callers that only serialize a selector. The
// catalog-aware predicate rejects them, and parseRequest rejects ambiguous request selectors.
return name;
}

/**
* Whether `modelId` is in a per-provider classification list (e.g. `noVisionModels`). Matches the full
* id, OR — for Ollama-style ids — the family before the ":size" tag, so a `gpt-oss` entry covers
* `gpt-oss:120b`/`gpt-oss:20b`. Colon-less ids (e.g. `grok-build-0.1`) still match exactly only.
*/
export function modelInList(list: string[] | undefined, modelId: string): boolean {
if (!list || list.length === 0) return false;
if (list.includes(modelId)) return true;
const colon = modelId.indexOf(":");
return colon > 0 && list.includes(modelId.slice(0, colon));
}

export type OcxToolChoice =
| "auto"
| "none"
| "required"
| { name: string }
| { allowedTools: string[]; mode: "auto" | "required" };

export function isAllowedToolChoice(value: OcxToolChoice | undefined): value is { allowedTools: string[]; mode: "auto" | "required" } {
return typeof value === "object" && value !== null && "allowedTools" in value;
}

/** Compile the request's tool-choice policy into a reusable advertisement/restoration predicate. */
export function toolChoiceToolPredicate(
choice: OcxToolChoice | undefined,
tools?: readonly Pick<OcxTool, "namespace" | "name">[],
): (tool: Pick<OcxTool, "namespace" | "name">) => boolean {
if (!choice || choice === "auto" || choice === "required") return () => true;
if (choice === "none") return () => false;
if (isAllowedToolChoice(choice)) {
const allowed = new Set(choice.allowedTools);
return tool => toolAllowedByChoice(tool, allowed, tools);
}
if (!tools) return tool => toolChoiceAliases(tool).includes(choice.name);
const candidates = toolChoiceCandidates(tools, choice.name);
return tool => candidates.length === 1 && sameToolIdentity(candidates[0], tool);
}
export type { OcxTool, OcxToolChoice } from "./types/tools";
export {
namespacedToolName,
toolChoiceAliases,
toolChoiceCandidates,
toolAllowedByChoice,
resolveToolChoiceWireName,
modelInList,
isAllowedToolChoice,
toolChoiceToolPredicate,
} from "./types/tools";

export interface OcxRequestOptions {
maxOutputTokens?: number;
Expand Down Expand Up @@ -1825,86 +1707,16 @@ export interface OcxProviderConfig {
nativeLocalExec?: "off" | "codex-sandbox" | "on";
}

/**
* Accepted values for the per-provider upstream HTTP-version pin (#1668). Shared by the
* zod load schema, the management write boundary (POST/PATCH), and the fetch runtime, so
* a value that one boundary accepts can never be rejected by another.
*/
export const UPSTREAM_HTTP_VERSION_VALUES = [
"auto",
"http1.1",
"h1",
"http2",
"h2",
] as const;

export type UpstreamHttpVersion = (typeof UPSTREAM_HTTP_VERSION_VALUES)[number];

export const REASONING_SUMMARY_DELIVERY_VALUES = [
"sequential",
"sequential_cutoff",
"concurrent",
"concurrent_cutoff",
] as const;

export type ReasoningSummaryDelivery = typeof REASONING_SUMMARY_DELIVERY_VALUES[number];

/** Trusted runtime ownership for Codex-account credentials. Never persisted per provider. */
export type CodexAccountMode = "direct" | "pool";

export const OPENAI_PROVIDER_TIER_VERSION = 2 as const;

/**
* Wires that a per-model `modelAdapters` override may select.
*
* Deliberately narrow: provider-specific adapters (cursor, kiro, google, ...) carry
* their own credential and base-URL semantics, so exposing them here would widen the
* auth boundary rather than pick a wire. Widening this set needs a per-adapter
* credential threat model first (#404).
*/
export const MODEL_ADAPTER_OVERRIDE_ALLOWED: ReadonlySet<string> = new Set([
"openai-chat",
"openai-responses",
]);

/**
* Providers whose listed model ids must be driven over the Anthropic wire even when
* the provider's configured adapter says otherwise — the upstream only speaks
* Anthropic for these models.
*/
const ANTHROPIC_WIRE_MODELS: Record<string, ReadonlySet<string>> = {
"opencode-go": new Set(["minimax-m2.5", "minimax-m2.7", "minimax-m3"]),
};

function anthropicWireModelsForProvider(providerName: string): ReadonlySet<string> | undefined {
return Object.hasOwn(ANTHROPIC_WIRE_MODELS, providerName)
? ANTHROPIC_WIRE_MODELS[providerName]
: undefined;
}

/** Detached provider-local hard-pin table for pure wire-policy resolution. */
export function captureWireAdapterHardPins(providerName: string): Readonly<Record<string, string>> {
const models = anthropicWireModelsForProvider(providerName);
if (!models) return Object.freeze({});
return Object.freeze(Object.fromEntries([...models].map(modelId => [modelId, "anthropic"])));
}

/**
* True when the upstream speaks exactly one wire for this model, so a configured
* override must not apply.
*
* Deliberately independent of the provider's current adapter: the wire resolver runs
* more than once per request, and a check phrased as "pin differs from the current
* adapter" would pass on the first pass and then let the override win on the second.
*/
export function isWirePinnedModel(providerName: string, modelId: string): boolean {
return anthropicWireModelsForProvider(providerName)?.has(modelId) ?? false;
}

/** The wire a pinned model must use, or undefined when the model is not pinned. */
export function pinnedWireAdapter(providerName: string, modelId: string): string | undefined {
return isWirePinnedModel(providerName, modelId) ? "anthropic" : undefined;
}
export type { UpstreamHttpVersion, ReasoningSummaryDelivery, CodexAccountMode } from "./types/wire";
export {
UPSTREAM_HTTP_VERSION_VALUES,
REASONING_SUMMARY_DELIVERY_VALUES,
OPENAI_PROVIDER_TIER_VERSION,
MODEL_ADAPTER_OVERRIDE_ALLOWED,
captureWireAdapterHardPins,
isWirePinnedModel,
pinnedWireAdapter,
} from "./types/wire";

export interface CodexAccount {
id: string;
Expand Down
Loading
Loading