/** * Role-based LLM resolution for CLEO (Phase 4 — T9306). * * Each call-site declares its semantic role (`extraction`, `consolidation`, * `derivation`, `hygiene`, `judgement`); the resolver walks the config to * find the configured provider/model/credential for that role and returns a * fully-wired SDK client plus its `CredentialResult`. * * ## Resolution chain (first match wins) * * Provider/model: * 1. `config.llm.roles[role]` — explicit per-role override * 2. `config.llm.default` — canonical default * 3. Implicit fallback — `anthropic` + {@link IMPLICIT_FALLBACK_MODEL} * * Credential: * 1. When `roles[role].credentialLabel` is set → `getCredentialByLabel(provider, label)` * 2. Else → `pickCredentialForProvider(provider, { strategy: 'priorityWithFallback' })` * (T-LLM-CRED Phase 2 multi-credential pool) * 3. Fallback → `resolveCredentials(provider, { projectRoot })` 6-tier chain * * The resolver never throws on missing credentials: it returns `credential: null` * so callers can preserve their existing graceful-degradation paths. * * @task T-LLM-CRED-CENTRALIZATION Phase 4 (T9306) * @module llm/role-resolver */ import type { Anthropic } from '@anthropic-ai/sdk'; import type { ApiMode, CredentialMetadataWire, ModelCaps, ResolutionSource, ResolveLLMForRoleOptions, RoleName, SealedCredential } from '@cleocode/contracts'; import type { OpenAI } from 'openai'; import { IMPLICIT_FALLBACK_MODEL } from './fallback-model.js'; import type { ModelTransport } from './types-config.js'; /** * Implicit fallback model used when no role-specific and no `default` entry is * present in config. Mirrors the historical hardcoded `claude-haiku-4-5-20251001` * that the 7 call-sites previously embedded. * * The literal is defined in the dependency-free leaf `./fallback-model.ts` (so * `config.ts` can read it without entering this module's circular import chain — * see that file's docs) and re-exported here so existing * `from './role-resolver.js'` consumers are unaffected. It still lives ONLY * under `packages/core/src/llm/`, keeping the T9255 grep guard clean. * * @task T9255 */ export { IMPLICIT_FALLBACK_MODEL }; /** * Implicit fallback provider matching {@link IMPLICIT_FALLBACK_MODEL}. * * @task T9255 */ export declare const IMPLICIT_FALLBACK_PROVIDER: ModelTransport; /** * Implicit fallback model for the `hygiene` role. * * Hygiene escalation runs longer reasoning prompts than the * consolidation/extraction tiers; the historical default has been * `claude-sonnet-4-6` (one tier up from {@link IMPLICIT_FALLBACK_MODEL}). * Centralised here so the grep guard catches any drift outside * `packages/core/src/llm/`. * * Only consulted when `resolveLLMForRole('hygiene')` returns * `source === 'implicit-fallback'` AND `model === IMPLICIT_FALLBACK_MODEL` * — i.e. no project/global config and no role pin supplied a model. * * @task T-LLM-CRED-CENTRALIZATION Phase 2 — DRY review P2-2 */ export declare const HYGIENE_FALLBACK_MODEL = "claude-sonnet-4-6"; /** * Concrete tagged union over the raw SDK client types used by this module. * This is the runtime-precise version of the opaque `unknown` carried by * `ResolvedLLM.client` in `@cleocode/contracts` — SDK classes are kept off * the contracts surface to preserve its zero-dependency footprint, but * tightened here where the SDK packages are real dependencies. * * NOTE (T9370): GoogleGenerativeAI removed from the union — `resolveLLMForRole` * is Anthropic-only in practice (the implicit fallback is always anthropic, and * non-Anthropic providers use the transport layer directly). The `Record` fallback * covers any future provider shape that doesn't use a typed SDK class here. * * @task T9255 * @task T9370 (D-ph4-01 factory retirement) */ export type LLMClient = Anthropic | OpenAI | Record; export type { ResolutionSource, ResolveLLMForRoleOptions }; /** * Result of {@link resolveLLMForRole} — runtime-precise envelope. * * The contracts-level `ResolvedLLM` (from `@cleocode/contracts`) carries * `client: unknown` to stay SDK-free. This core variant tightens the same * envelope with the concrete SDK union (`LLMClient`) — same shape, narrower * `client` type. * * `client` is `null` only when {@link sealedCredential} is also null — in which * case the caller MUST fall back to its graceful-degradation path * (return null / skip / log warn). * * ## E10 — no inline plaintext (T11753) * * The secret-bearing `credential.apiKey` field is **gone**. `credential` is now * non-secret {@link CredentialMetadataWire} metadata (provider / source / * authType) so callers can still branch on auth scheme and surface diagnostics. * The plaintext token is reachable ONLY via {@link sealedCredential}'s `fetch()` * at the wire (`transportForProvider` / `session-factory.ts`) or daemon * worker-injection — it never crosses this envelope. * * @task T9255 * @task T11753 */ export interface ResolvedLLM { /** LLM provider transport that was resolved. */ provider: ModelTransport; /** Full model identifier. */ model: string; /** * Fully-wired SDK client. `null` when no credential is available. * For Anthropic: constructed via `new Anthropic(...)` honoring OAuth/api_key. * For other providers: `null` (use the transport layer directly via `getLlmExecutor`). */ client: LLMClient | null; /** * Resolved credential **metadata** (provider / source / authType) — NO * plaintext. `null` when none of the 6 credential tiers produced a token * (paired with `sealedCredential === null`). Callers MUST handle this case * and obtain the secret via {@link sealedCredential}. * * @task T11753 */ credential: CredentialMetadataWire | null; /** * Sealed credential handle — the canonical, on-demand credential surface * (E10 · T11752 · T11753). `null` exactly when `credential` is `null`. * * The plaintext is materialized ONLY by `sealedCredential.fetch()` at the * wire / daemon worker-injection — never returned up this envelope. * * @task T11753 */ sealedCredential: SealedCredential | null; /** Which config path produced this resolution. */ source: ResolutionSource; /** When `roles[role].credentialLabel` was set, the label that was used. */ credentialLabel?: string; /** * Wire protocol spoken by the resolved provider/credential pair (E9 · T11745). * * The load-bearing SSoT addition: the single {@link import('./model-runner.js').ModelRunner} * branches on this to construct the correct transport — `'codex_responses'` * routes a ChatGPT OAuth token through the Responses API; everything else * follows {@link deriveApiWire}. Derived in {@link resolveLLMForRole} from the * resolved `provider` + `credential.authType`. */ apiMode: ApiMode; /** * Per-provider API endpoint override implied by the resolution. `null` when * the transport's own default (or the credential's `baseUrl`) should win. * Currently carries the codex ChatGPT-backend URL for OAuth openai resolution. */ baseUrl: string | null; /** * Scheme used to present the credential. Mirrors `credential.authType`, * surfaced at the top level so the runner does not have to reach into the * credential. `null` when no credential was resolved. */ authType: 'api_key' | 'oauth' | 'aws_sdk' | null; /** * Coarse capability hints (tools/json/vision/thinking) so a runner can pick * the right call shape. Optional — absent means "unknown". */ capabilities?: ModelCaps; } /** * Resolve the LLM client + credential for a logical role. * * See module docs for the full resolution algorithm. Never throws — when * no credential is reachable the caller receives * `{ credential: null, sealedCredential: null, client: null, ... }` and is * responsible for its own graceful-degradation path (the previous Phase 1 * pattern was a `return null` shortcut at the call-site). * * E10 (T11753): the plaintext token never rides on the returned envelope. * Materialize it ONLY at the wire by calling `llm.sealedCredential.fetch()`. * * @example * ```ts * const llm = await resolveLLMForRole('consolidation', { projectRoot }); * if (!llm.sealedCredential || !llm.client) { * return null; // graceful no-op * } * // At the wire — the ONLY place the plaintext is materialized: * const token = (await llm.sealedCredential.fetch()).value; * const response = await fetch('https://api.anthropic.com/v1/messages', { * headers: { * 'Content-Type': 'application/json', * ...authHeaders({ provider: llm.provider, apiKey: token, * source: llm.credential?.source, authType: llm.credential?.authType ?? 'api_key' }), * }, * body: JSON.stringify({ model: llm.model, ...rest }), * }); * ``` * * @param role - Logical role name (see {@link RoleName}). * @param opts - Optional overrides (project root for config + tier-5 lookup). * @returns A {@link ResolvedLLM} envelope; never throws. * * @task T9255 * @task T11753 */ export declare function resolveLLMForRole(role: RoleName, opts?: ResolveLLMForRoleOptions): Promise; /** * Convenience wrapper over {@link resolveLLMForRole} that narrows the client * union to the Anthropic Messages API surface. * * Returns `null` when: * - no Anthropic credential is reachable for the role, OR * - the resolved provider is not `'anthropic'`, OR * - the SDK client could not be constructed. * * Eliminates the `as unknown as Pick` cast that the * three Anthropic-only call-sites (memory/llm-extraction, deriver/deriver, * sentient/dream-cycle) previously required. AGENTS.md explicitly forbids * `as unknown as X` casts — this helper is the supported alternative. * * @example * ```ts * const llm = await resolveAnthropicForRole('extraction', { projectRoot }); * if (!llm) return null; // graceful no-op — no credential or wrong provider * const response = await llm.client.messages.create({ * model: llm.model, * max_tokens: 256, * messages: [{ role: 'user', content: prompt }], * }); * ``` * * @param role - Logical role name (see `RoleName`). * @param opts - Optional overrides (project root for config + tier-5 lookup). * @returns Typed envelope, or `null` when graceful no-op is required. * * @task T-LLM-CRED-CENTRALIZATION Phase 2 — DRY review P2-1 */ export declare function resolveAnthropicForRole(role: RoleName, opts?: ResolveLLMForRoleOptions): Promise<{ client: Pick; model: string; } | null>; //# sourceMappingURL=role-resolver.d.ts.map