mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-07-26 01:25:35 +00:00
refactor(agent-core-v2): decouple kosong from config persistence (#2068)
Some checks are pending
CI / test (4) (push) Waiting to run
CI / test (5) (push) Waiting to run
CI / test-pi-tui (push) Waiting to run
CI / test-windows (push) Waiting to run
CI / lint (push) Waiting to run
CI / build (push) Waiting to run
CI / test (1) (push) Waiting to run
CI / test (2) (push) Waiting to run
CI / test (3) (push) Waiting to run
CI / typecheck (push) Waiting to run
Nix Build / Check flake.nix workspace sync (push) Waiting to run
Nix Build / nix build .#kimi-code (push) Blocked by required conditions
Release / Native release artifact (push) Blocked by required conditions
Release / Release (push) Waiting to run
Release / Deploy docs (push) Blocked by required conditions
Release / Publish native release assets (push) Blocked by required conditions
Some checks are pending
CI / test (4) (push) Waiting to run
CI / test (5) (push) Waiting to run
CI / test-pi-tui (push) Waiting to run
CI / test-windows (push) Waiting to run
CI / lint (push) Waiting to run
CI / build (push) Waiting to run
CI / test (1) (push) Waiting to run
CI / test (2) (push) Waiting to run
CI / test (3) (push) Waiting to run
CI / typecheck (push) Waiting to run
Nix Build / Check flake.nix workspace sync (push) Waiting to run
Nix Build / nix build .#kimi-code (push) Blocked by required conditions
Release / Native release artifact (push) Blocked by required conditions
Release / Release (push) Waiting to run
Release / Deploy docs (push) Blocked by required conditions
Release / Publish native release assets (push) Blocked by required conditions
* refactor(agent-core-v2): decouple kosong from config persistence
- keep the kosong provider/model registries in memory and sync them with
config.toml through a new app/kosongConfig two-way bridge: hydrate on
startup, push config section changes into the registries, and persist
runtime mutations (discovery refresh, OAuth provisioning, default-model
pointer changes) back to disk
- declare the providers/models/thinking section constants, zod schemas,
env bindings, and TOML transforms in a single app/kosongConfig
configSection.ts; kosong keeps hand-written types only
- pin every schema to its kosong type at compile time via
AssertExact<Equal<...>> (_base/utils/typeEquality)
- register a transitional auth>kosongConfig exception in the domain-layer
checker for the OAuth provisioning flows
* chore(agent-core-v2): drop unused IConfigService import from authLegacy
* fix(agent-core-v2): reconcile registry with env-pinned default pointers
A registry-originated default-model/provider write lands only in the
config user layer when an effective overlay pins the section
(KIMI_MODEL_NAME pins defaultModel to the reserved env model): the
effective value does not move and no change event fires, so the registry
diverged from the effective config view — catalog/auth reads reported the
user pick while profile resolution kept the pinned model. After
persisting, the bridge now re-asserts the effective value into the
registry, restoring the pre-refactor behavior where every default-pointer
write was arbitrated by the effective view.
* fix(agent-core-v2): await persistence before resolving kosong registry mutations
ProviderService/ModelService mutations resolved as soon as the in-memory
registry updated, while the kosongConfig bridge persisted the change on an
unawaited private chain — callers (klient kosong.*, set_default route,
OAuth provision, discovery refresh) could observe success before the write
reached config.toml, and a restart right after could lose it.
- fire registry change events through AsyncEmitter and await delivery in
set/delete/replaceAll/setDefaultX, so a mutation resolves only after
listeners' waitUntil work completes; loadAll keeps synchronous timing
- the bridge hooks its persists into waitUntil, hoists the equality guards
into the listeners so config-originated echoes stay fully synchronous,
and serializes persists on the chain as before
- retry a failed persist with bounded backoff (3 attempts); failures are
logged, never rejected to callers, and the in-memory change stands
- default-pointer change events now carry an { id } payload (fireAsync
requires object events)
- add the klient kosong-config stress example covering read-after-write,
concurrent bursts, and restart durability
This commit is contained in:
parent
e0f2a41769
commit
188c0fcbf7
56 changed files with 2590 additions and 1115 deletions
|
|
@ -92,7 +92,7 @@ pass `ConfigTarget.Memory` for a per-run override that is never written to disk.
|
|||
- `src/profile/thinking.ts` (owner domain, not `config`) — the `resolveThinkingEffort` helper; uses the authoritative `ThinkingConfig` from `configSection.ts`.
|
||||
- `src/config/configPure.ts` — `isPlainObject`, `deepMerge`, `omitUndefined`, `describeUnknownError`.
|
||||
|
||||
A domain that owns a section keeps the schema in its own `configSection.ts` (e.g. `src/flag/flag.ts` for `experimental`, `src/profile/configSection.ts` for `thinking`, `src/loop/configSection.ts` for `loopControl`). A cross-section env overlay (e.g. the `KIMI_MODEL_*` synthesis) lives in the owning domain too (`src/provider/envOverlay.ts`) and is registered via `IConfigRegistry.registerEffectiveOverlay`.
|
||||
A domain that owns a section keeps the schema in its own `configSection.ts` (e.g. `src/flag/flag.ts` for `experimental`, `src/loop/configSection.ts` for `loopControl`). Exception: kosong-owned sections (`providers`, `models`, `thinking`) — kosong is a pure, persistence-free abstraction layer that defines only the types (`src/kosong/{provider,model}`); the section constants, the zod schemas (re-derived from those types and compile-time pinned via `AssertExact<Equal<z.infer<typeof Schema>, Type>>`, see `_base/utils/typeEquality.ts`), the registrations, env bindings, and TOML transforms all live in the persistence wrapper `src/app/kosongConfig/configSection.ts`. (`modelCatalog` has no kosong-side type at all — its section is fully self-contained in `app/kosongConfig`.) A cross-section env overlay (e.g. the `KIMI_MODEL_*` synthesis) lives in the wrapper too (`src/app/kosongConfig/envOverlay.ts`) and is registered via `IConfigRegistry.registerEffectiveOverlay`. The two-way sync between config sections and kosong's in-memory registries is owned by `IKosongConfigService` (`src/app/kosongConfig/kosongConfigService.ts`).
|
||||
|
||||
## Scope
|
||||
|
||||
|
|
@ -236,7 +236,7 @@ This means registration order is never a correctness concern — you do not need
|
|||
|
||||
### `KIMI_MODEL_*` env overlay
|
||||
|
||||
When `KIMI_MODEL_NAME` is set, the `provider` domain's `kimiModelEnvOverlay` (`src/provider/envOverlay.ts`) injects a reserved model alias (`__kimi_env_model__`) into `effective`, points `defaultModel` at it, and merges the request `modelOverrides`; the reserved provider (`__kimi_env__`) comes from the `providers` section env bindings. The overlay is registered via `IConfigRegistry.registerEffectiveOverlay` and applied **only to `effective`**, never to `rawSnake`, so it is never persisted. Its `strip` (plus the providers section `stripEnv`) is the final guard so a caller that read `effective` (with the overlay) cannot write the reserved entries or the shell API key back to disk. `config` itself only runs registered overlays — it does not know the `KIMI_MODEL_*` semantics.
|
||||
When `KIMI_MODEL_NAME` is set, the `kosongConfig` wrapper's `kimiModelEnvOverlay` (`src/app/kosongConfig/envOverlay.ts`) injects a reserved model alias (`__kimi_env_model__`) into `effective`, points `defaultModel` at it, and merges the request `modelOverrides`; the reserved provider (`__kimi_env__`) comes from the `providers` section env bindings. The overlay is registered via `IConfigRegistry.registerEffectiveOverlay` and applied **only to `effective`**, never to `rawSnake`, so it is never persisted. Its `strip` (plus the providers section `stripEnv`) is the final guard so a caller that read `effective` (with the overlay) cannot write the reserved entries or the shell API key back to disk. `config` itself only runs registered overlays — it does not know the `KIMI_MODEL_*` semantics.
|
||||
|
||||
## Owner-owned sections
|
||||
|
||||
|
|
@ -246,13 +246,13 @@ When `KIMI_MODEL_NAME` is set, the `provider` domain's `kimiModelEnvOverlay` (`s
|
|||
|
||||
| Section | Owner | Layer | Status |
|
||||
|---|---|---|---|
|
||||
| `providers` | `provider` | L2 | owner-owned (`IProviderService` CRUD) |
|
||||
| `providers` / `defaultProvider` | `kosongConfig` (types: `kosong/provider`) | L3/L2 | owner-owned (`IProviderService` CRUD) |
|
||||
| `experimental` | `flag` | L3 | owner-owned |
|
||||
| `thinking` | `profile` | L4 | owner-owned |
|
||||
| `thinking` | `kosongConfig` (type: `kosong/model/thinking`) | L3/L2 | owner-owned |
|
||||
| `loopControl` | `loop` | L4 | owner-owned (read by `loop` + `profile`) |
|
||||
| `McpServerConfig` (type) | `mcp` | L5 | owner-owned (type only; not a registered section) |
|
||||
| `session` | `config` | L2 | in config |
|
||||
| `models` / `defaultModel` / `defaultProvider` | `kosong` | L1 | owner-owned (read by `ProviderManager`) |
|
||||
| `models` / `defaultModel` | `kosongConfig` (types: `kosong/model`) | L3/L2 | owner-owned (`IModelService` CRUD) |
|
||||
| `hooks` | `externalHooks` | L4 | owner-owned |
|
||||
| `permission` | `permissionRules` | L3 | owner-owned |
|
||||
| `background` | `background` | L5 | owner-owned |
|
||||
|
|
|
|||
5
.changeset/kosong-persistence-bridge.md
Normal file
5
.changeset/kosong-persistence-bridge.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"@moonshot-ai/kimi-code": patch
|
||||
---
|
||||
|
||||
Decouple provider and model management from config persistence on the experimental engine: the runtime keeps its own provider/model registry, and a dedicated sync layer hydrates it from config.toml at startup and writes runtime changes (added providers, discovered models, default-model selection) back to disk.
|
||||
|
|
@ -251,6 +251,14 @@ const DOMAIN_LAYER = new Map([
|
|||
['kosong/protocol', 1],
|
||||
['kosong/provider', 2],
|
||||
['kosong/model', 2],
|
||||
// `kosongConfig` (App, L3) is the persistence wrapper over kosong: it
|
||||
// declares the kosong-owned config sections (constants + zod schemas
|
||||
// re-derived from kosong's pure types, compile-time pinned) and their
|
||||
// env-overlay registrations, the two-way config ↔ kosong sync bridge, the
|
||||
// OAuth token adapter, and the discovery orchestrator. It may import
|
||||
// `config`/`auth`/`event` (L1–L2) and every kosong layer; kosong never
|
||||
// imports it back.
|
||||
['kosongConfig', 3],
|
||||
]);
|
||||
|
||||
const V1_PACKAGE = '@moonshot-ai/agent-core';
|
||||
|
|
@ -281,11 +289,14 @@ const KOSONG_LAYER = new Map([
|
|||
]);
|
||||
|
||||
/**
|
||||
* Kosong subdomains whose non-kosong imports are restricted to `_base`
|
||||
* utilities (`contract` is the pure wire contract; `protocol` is L1 trait
|
||||
* interfaces — only `_base` + `contract`).
|
||||
* Kosong is a pure provider/model abstraction layer: NO kosong subdomain may
|
||||
* import another v2 domain outside kosong itself — only `_base` utilities
|
||||
* are allowed. (`protocol` additionally sees `kosong/contract`, handled by
|
||||
* Rule 3b above.) Config persistence, OAuth tokens, events, and discovery
|
||||
* orchestration all live in the upper `app/kosongConfig` wrapper — kosong
|
||||
* must never reach up to them.
|
||||
*/
|
||||
const KOSONG_BASE_ONLY_SUBDOMAINS = new Set(['contract', 'protocol']);
|
||||
const KOSONG_BASE_ONLY_SUBDOMAINS = new Set(['contract', 'protocol', 'provider', 'model']);
|
||||
|
||||
/**
|
||||
* Wire SDK packages the pure kosong layers must never import — not even
|
||||
|
|
@ -398,6 +409,9 @@ const ALLOWED_EXCEPTIONS = new Set([
|
|||
'bootstrap>skillCatalog',
|
||||
// bootstrap is the composition root — it wires backends by design.
|
||||
'bootstrap>persistence/backends',
|
||||
// bootstrap instantiates the kosong persistence bridge eagerly so kosong's
|
||||
// registries are hydrated before any consumer can await their `ready`.
|
||||
'bootstrap>kosongConfig',
|
||||
// `auth` (KimiOAuth, L2) owns the OAuth-backed `WebSearch` tool and registers
|
||||
// it through the tool contribution API, so it reaches up to the L3 tool
|
||||
// contract and registry. Surfaced for review: the tool needs an authenticated
|
||||
|
|
@ -405,6 +419,11 @@ const ALLOWED_EXCEPTIONS = new Set([
|
|||
// auth-independent `web` domain.
|
||||
'auth>tool',
|
||||
'auth>toolRegistry',
|
||||
// Transitional: `auth` (L2) reads/writes the kosong-owned config sections
|
||||
// (providers/models/thinking), whose constants and schemas are declared by
|
||||
// the `kosongConfig` persistence wrapper (L3), when provisioning or clearing
|
||||
// OAuth-managed config. Slated for cleanup with the auth layering rework.
|
||||
'auth>kosongConfig',
|
||||
// `toolApproval` (Agent, L3) owns the approval round-trip for permissionGate
|
||||
// asks and plan/goal reviews, driven through the Session approval broker.
|
||||
'toolApproval>approval',
|
||||
|
|
@ -617,16 +636,17 @@ export function checkSource(source, absFile) {
|
|||
continue;
|
||||
}
|
||||
|
||||
// Rule 3c: outside the kosong subtree, the pure layers may only depend
|
||||
// on `_base` utilities (`protocol` additionally sees `kosong/contract`,
|
||||
// handled by Rule 3b above).
|
||||
// Rule 3c: outside the kosong subtree, kosong code may only depend on
|
||||
// `_base` utilities (`protocol` additionally sees `kosong/contract`,
|
||||
// handled by Rule 3b above). This is what keeps kosong a pure
|
||||
// abstraction layer with no upward dependencies.
|
||||
if (sourceKosong !== undefined && KOSONG_BASE_ONLY_SUBDOMAINS.has(sourceKosong.sub)) {
|
||||
const targetDomain = targetDomainOf(targetAbs);
|
||||
if (targetDomain !== '_base') {
|
||||
violations.push({
|
||||
file: absFile,
|
||||
line,
|
||||
message: `'kosong/${sourceKosong.sub}' must not import domain '${targetDomain ?? specifier}' via '${specifier}' — only _base utilities are allowed outside the kosong subtree`,
|
||||
message: `'kosong/${sourceKosong.sub}' must not import domain '${targetDomain ?? specifier}' via '${specifier}' — kosong is a pure abstraction layer: only _base utilities are allowed outside the kosong subtree (persistence/OAuth/discovery live in app/kosongConfig)`,
|
||||
});
|
||||
}
|
||||
continue;
|
||||
|
|
|
|||
26
packages/agent-core-v2/src/_base/utils/typeEquality.ts
Normal file
26
packages/agent-core-v2/src/_base/utils/typeEquality.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
/**
|
||||
* Compile-time type equality.
|
||||
*
|
||||
* Used to pin a hand-written type to the zod schema that re-derives it —
|
||||
* e.g. kosong's persistence-free types vs the section schemas their
|
||||
* `kosongConfig` wrapper registers: a drift in either direction (added /
|
||||
* removed field, changed field type, optionality flip) fails typecheck.
|
||||
*
|
||||
* `Equal` compares by mutual assignability through a contravariant
|
||||
* function-type trick, so it is stricter than a one-way `A extends B`
|
||||
* check. Both sides are flattened first (a homomorphic mapped type), so a
|
||||
* schema-side intersection (e.g. the `{...} & { [k: string]: unknown }`
|
||||
* that a passthrough object infers to) compares equal to the equivalent
|
||||
* hand-written object type instead of failing on type-node shape. The
|
||||
* comparison cannot see `readonly` modifiers (an inherent TS limitation),
|
||||
* so hand-written types should match zod's mutable inference exactly.
|
||||
*/
|
||||
|
||||
type Flatten<T> = { [K in keyof T]: T[K] } & {};
|
||||
|
||||
export type Equal<A, B> =
|
||||
(<T>() => T extends Flatten<A> ? 1 : 2) extends <T>() => T extends Flatten<B> ? 1 : 2
|
||||
? true
|
||||
: false;
|
||||
|
||||
export type AssertExact<T extends true> = T;
|
||||
|
|
@ -68,9 +68,10 @@ import {
|
|||
type ModelRequestTiming,
|
||||
} from '#/kosong/model/modelRequester';
|
||||
import type { ModelOverrides } from '#/kosong/model/model.types';
|
||||
import { MODELS_SECTION, type ModelsSection } from '#/kosong/model/model';
|
||||
import { IModelService } from '#/kosong/model/model';
|
||||
import { completionBudgetParams, resolveCompletionBudget } from '#/kosong/model/completionBudget';
|
||||
import { resolveThinkingKeep, THINKING_SECTION, type ThinkingConfig } from '#/kosong/model/thinking';
|
||||
import { resolveThinkingKeep, type ThinkingConfig } from '#/kosong/model/thinking';
|
||||
import { THINKING_SECTION } from '#/app/kosongConfig/configSection';
|
||||
import type { Protocol } from '#/kosong/protocol/protocol';
|
||||
import type { ApiErrorEvent } from '#/app/telemetry/events';
|
||||
import { ITelemetryService } from '#/app/telemetry/telemetry';
|
||||
|
|
@ -158,6 +159,7 @@ export class AgentLLMRequesterService implements IAgentLLMRequesterService {
|
|||
@IAgentProfileService private readonly profile: IAgentProfileService,
|
||||
@IAgentUsageService private readonly usage: IAgentUsageService,
|
||||
@IConfigService private readonly config: IConfigService,
|
||||
@IModelService private readonly modelService: IModelService,
|
||||
@IModelCatalog private readonly modelCatalog: IModelCatalog,
|
||||
@ILogService private readonly log: ILogService,
|
||||
@ITelemetryService private readonly telemetry: ITelemetryService,
|
||||
|
|
@ -620,9 +622,8 @@ export class AgentLLMRequesterService implements IAgentLLMRequesterService {
|
|||
const systemPromptHash = fingerprint(input.systemPrompt);
|
||||
const overrides = this.config.get<ModelOverrides>('modelOverrides');
|
||||
const thinkingConfig = this.config.get<ThinkingConfig>(THINKING_SECTION);
|
||||
const models = this.config.get<ModelsSection>(MODELS_SECTION);
|
||||
const modelConfig =
|
||||
input.modelAlias === undefined ? undefined : models?.[input.modelAlias];
|
||||
input.modelAlias === undefined ? undefined : this.modelService.get(input.modelAlias);
|
||||
const payload: PayloadOf<typeof llmRequest> = {
|
||||
kind: requestKindForRecord(fields),
|
||||
provider: input.protocol,
|
||||
|
|
|
|||
|
|
@ -62,10 +62,10 @@ import {
|
|||
resolveForcedThinkingEffort,
|
||||
resolveThinkingEffortForModel,
|
||||
resolveThinkingKeep,
|
||||
THINKING_SECTION,
|
||||
requiresStrictThinkingValidation,
|
||||
type ThinkingConfig,
|
||||
} from '#/kosong/model/thinking';
|
||||
import { THINKING_SECTION } from '#/app/kosongConfig/configSection';
|
||||
import { DEFAULT_AGENT_PROFILE_NAME, IAgentProfileCatalogService } from '#/app/agentProfileCatalog/agentProfileCatalog';
|
||||
import { ErrorCodes, Error2 } from "#/errors";
|
||||
import { IBootstrapService } from '#/app/bootstrap/bootstrap';
|
||||
|
|
|
|||
|
|
@ -55,13 +55,18 @@ import {
|
|||
nonEmpty,
|
||||
resolveModelAuthMaterial,
|
||||
} from '#/kosong/model/modelAuth';
|
||||
import { DEFAULT_MODEL_SECTION, type ModelRecord, MODELS_SECTION } from '#/kosong/model/model';
|
||||
import { IModelService, type ModelRecord } from '#/kosong/model/model';
|
||||
import {
|
||||
DEFAULT_MODEL_SECTION,
|
||||
MODELS_SECTION,
|
||||
PROVIDERS_SECTION,
|
||||
THINKING_SECTION,
|
||||
} from '#/app/kosongConfig/configSection';
|
||||
import {
|
||||
IProviderService,
|
||||
type OAuthRef,
|
||||
type ProviderConfig,
|
||||
type ProvidersChangedEvent,
|
||||
PROVIDERS_SECTION,
|
||||
} from '#/kosong/provider/provider';
|
||||
import { isOAuthCatalogVendor } from '#/kosong/provider/providerDefinition';
|
||||
import { ITelemetryService } from '#/app/telemetry/telemetry';
|
||||
|
|
@ -78,7 +83,6 @@ import {
|
|||
|
||||
const TERMINAL_RETENTION_MS = 5 * 60 * 1000;
|
||||
const DEFAULT_DEVICE_EXPIRES_IN_SEC = 15 * 60;
|
||||
const THINKING_SECTION = 'thinking';
|
||||
const SERVICES_SECTION = 'services';
|
||||
|
||||
interface FlowState {
|
||||
|
|
@ -585,6 +589,7 @@ export class AuthSummaryService implements IAuthSummaryService {
|
|||
|
||||
constructor(
|
||||
@IProviderService private readonly providerService: IProviderService,
|
||||
@IModelService private readonly modelService: IModelService,
|
||||
@IConfigService private readonly config: IConfigService,
|
||||
@IOAuthService private readonly oauth: IOAuthService,
|
||||
@ILogService private readonly log: ILogService,
|
||||
|
|
@ -614,10 +619,14 @@ export class AuthSummaryService implements IAuthSummaryService {
|
|||
}
|
||||
|
||||
async ensureReady(modelOverride?: string): Promise<void> {
|
||||
// Reload so external file edits reach the kosong registries through the
|
||||
// persistence bridge, then read the RUNTIME state from the registries —
|
||||
// the config sections are only their persistence and may lag a pending
|
||||
// kosong-originated persist.
|
||||
await this.config.reload();
|
||||
const providers = this.providerService.list();
|
||||
const models = this.config.get<Record<string, ModelRecord> | undefined>(MODELS_SECTION) ?? {};
|
||||
const modelId = modelOverride ?? this.config.get<string | undefined>(DEFAULT_MODEL_SECTION);
|
||||
const models = this.modelService.list();
|
||||
const modelId = modelOverride ?? this.modelService.getDefaultModel();
|
||||
const configured = modelId === undefined || modelId === '' ? undefined : models[modelId];
|
||||
if (Object.keys(providers).length === 0 && !isProviderlessModel(configured)) {
|
||||
throw new AuthProvisioningRequiredError();
|
||||
|
|
|
|||
|
|
@ -26,12 +26,24 @@ import {
|
|||
snakeToCamel,
|
||||
transformPlainObject,
|
||||
} from '#/app/config/toml';
|
||||
import { OAuthRefSchema } from '#/kosong/provider/provider';
|
||||
import { type AssertExact, type Equal } from '#/_base/utils/typeEquality';
|
||||
import type { OAuthRef } from '#/kosong/provider/provider';
|
||||
|
||||
export const SERVICES_SECTION = 'services';
|
||||
|
||||
const StringRecordSchema = z.record(z.string(), z.string());
|
||||
|
||||
// Local re-derivation of kosong's `OAuthRef` type: the canonical section
|
||||
// schema lives in `app/kosongConfig` (L3), which this L2 domain must not
|
||||
// import. The `AssertExact` pin keeps this copy in lockstep with the type.
|
||||
const OAuthRefSchema = z.object({
|
||||
storage: z.enum(['file', 'keyring']),
|
||||
key: z.string().min(1),
|
||||
oauthHost: z.string().min(1).optional(),
|
||||
});
|
||||
|
||||
type _AssertOAuthRef = AssertExact<Equal<z.infer<typeof OAuthRefSchema>, OAuthRef>>;
|
||||
|
||||
export const MoonshotServiceConfigSchema = z.object({
|
||||
baseUrl: z.string().optional(),
|
||||
apiKey: z.string().optional(),
|
||||
|
|
|
|||
|
|
@ -2,12 +2,13 @@
|
|||
* `authLegacy` domain — `IAuthLegacyService` implementation.
|
||||
*
|
||||
* Stateless App-scope projector: reads the configured providers through
|
||||
* `provider`, the global default-model selection through `config`, and the
|
||||
* managed OAuth provider's cached-token state through `auth`, then assembles
|
||||
* the v1 `AuthSummary`. The computation mirrors v1's `AuthSummaryService.get()`
|
||||
* so the `/api/v1/auth` envelope is byte-compatible. No business logic is
|
||||
* duplicated; the native `IAuthSummaryService` (which serves `/api/v2`) is not
|
||||
* involved.
|
||||
* `provider`, the global default-model selection through `model` (the
|
||||
* kosong registry is the runtime source of truth; config is only its
|
||||
* persistence), and the managed OAuth provider's cached-token state through
|
||||
* `auth`, then assembles the v1 `AuthSummary`. The computation mirrors v1's
|
||||
* `AuthSummaryService.get()` so the `/api/v1/auth` envelope is
|
||||
* byte-compatible. No business logic is duplicated; the native
|
||||
* `IAuthSummaryService` (which serves `/api/v2`) is not involved.
|
||||
*/
|
||||
|
||||
import { KIMI_CODE_PROVIDER_NAME } from '@moonshot-ai/kimi-code-oauth';
|
||||
|
|
@ -16,8 +17,7 @@ import type { AuthSummary } from './authLegacy';
|
|||
import { InstantiationType } from '#/_base/di/extensions';
|
||||
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
|
||||
import { IOAuthService } from '#/app/auth/auth';
|
||||
import { IConfigService } from '#/app/config/config';
|
||||
import { DEFAULT_MODEL_SECTION } from '#/kosong/model/model';
|
||||
import { IModelService } from '#/kosong/model/model';
|
||||
import { IProviderService } from '#/kosong/provider/provider';
|
||||
|
||||
import { IAuthLegacyService } from './authLegacy';
|
||||
|
|
@ -29,16 +29,18 @@ export class AuthLegacyService implements IAuthLegacyService {
|
|||
|
||||
constructor(
|
||||
@IProviderService private readonly providerService: IProviderService,
|
||||
@IConfigService private readonly config: IConfigService,
|
||||
@IModelService private readonly modelService: IModelService,
|
||||
@IOAuthService private readonly oauth: IOAuthService,
|
||||
) {}
|
||||
|
||||
async get(): Promise<AuthSummary> {
|
||||
await this.config.ready;
|
||||
// The kosong registries become ready once the persistence bridge has
|
||||
// hydrated them from config — that is the readiness this projection needs.
|
||||
await this.modelService.ready;
|
||||
|
||||
const providers = this.providerService.list();
|
||||
const providers_count = Object.keys(providers).length;
|
||||
const default_model = nonEmpty(this.config.get<string>(DEFAULT_MODEL_SECTION));
|
||||
const default_model = nonEmpty(this.modelService.getDefaultModel());
|
||||
|
||||
let managed_provider: AuthSummary['managed_provider'] = null;
|
||||
if (providers[MANAGED_PROVIDER_NAME] !== undefined) {
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ import {
|
|||
import { FileStorageService } from '#/persistence/backends/node-fs/fileStorageService';
|
||||
import { FileSkillDiscovery } from '#/app/skillCatalog/fileSkillDiscovery';
|
||||
import { ISkillDiscovery } from '#/app/skillCatalog/skillDiscovery';
|
||||
import { IKosongConfigService } from '#/app/kosongConfig/kosongConfig';
|
||||
|
||||
export interface IBootstrapOptions {
|
||||
readonly homeDir: string;
|
||||
|
|
@ -120,6 +121,10 @@ export function bootstrap(input: BootstrapInput = {}, extraSeeds: ScopeSeed = []
|
|||
const app = createAppScope({
|
||||
extra: [...bootstrapSeed(input), ...storageSeed(options), ...skillSeed(), ...extraSeeds],
|
||||
});
|
||||
// Instantiate the kosong persistence bridge eagerly: kosong's registries
|
||||
// only become `ready` once the bridge has hydrated them from config, and
|
||||
// Eager registration alone never constructs a service.
|
||||
app.accessor.get(IKosongConfigService);
|
||||
return { app };
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,12 +1,16 @@
|
|||
/**
|
||||
* `config` domain error codes.
|
||||
*
|
||||
* The `config.invalid` code string is owned by the kosong L0 wire contract
|
||||
* (`kosong/contract/errors.ts`); this module only registers it.
|
||||
*/
|
||||
|
||||
import { registerErrorDomain, type ErrorDomain } from '#/_base/errors/codes';
|
||||
import { CONFIG_INVALID_ERROR_CODE } from '#/kosong/contract/errors';
|
||||
|
||||
export const ConfigErrors = {
|
||||
codes: {
|
||||
CONFIG_INVALID: 'config.invalid',
|
||||
CONFIG_INVALID: CONFIG_INVALID_ERROR_CODE,
|
||||
},
|
||||
} as const satisfies ErrorDomain;
|
||||
|
||||
|
|
|
|||
358
packages/agent-core-v2/src/app/kosongConfig/configSection.ts
Normal file
358
packages/agent-core-v2/src/app/kosongConfig/configSection.ts
Normal file
|
|
@ -0,0 +1,358 @@
|
|||
/**
|
||||
* `kosongConfig` domain (L3) — config-section declarations for kosong.
|
||||
*
|
||||
* The persistence wrapper for kosong's provider/model registries and the
|
||||
* thinking / model-catalog preferences: declares every kosong-owned section
|
||||
* constant and its zod schema, plus the env bindings / write-path strips
|
||||
* and the snake_case ↔ camelCase TOML transforms. Where kosong owns a pure
|
||||
* type (`providers` / `models` / `thinking`), the schema is re-derived from
|
||||
* it and pinned by an `AssertExact` assertion (schema ≡ type at compile
|
||||
* time); `modelCatalog` has no kosong-side type and keeps its own local
|
||||
* schema. Self-registered at module load via `registerConfigSection`, so
|
||||
* the `config` domain never imports kosong types.
|
||||
*
|
||||
* `ProviderTypeSchema` is deliberately free-form text: vendor identity is
|
||||
* NOT enumerated at parse time. Validation happens at resolve time against
|
||||
* kosong's provider-definition registry, which is what allows external
|
||||
* packages to register new vendors without touching this schema.
|
||||
*
|
||||
* Side-effect module: production gets it from the `src/index.ts`
|
||||
* side-effect block; tests import it on demand.
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
import { type ConfigStripEnv, envBindings } from '#/app/config/config';
|
||||
import { registerConfigSection } from '#/app/config/configSectionContributions';
|
||||
import {
|
||||
camelToSnake,
|
||||
cloneRecord,
|
||||
isPlainObject,
|
||||
plainObjectToToml,
|
||||
setDefined,
|
||||
snakeToCamel,
|
||||
transformPlainObject,
|
||||
} from '#/app/config/toml';
|
||||
import { type AssertExact, type Equal } from '#/_base/utils/typeEquality';
|
||||
import type { ModelOverride, ModelRecord, ModelsSection } from '#/kosong/model/model';
|
||||
import type { ThinkingConfig } from '#/kosong/model/thinking';
|
||||
import type { OAuthRef, ProviderConfig, ProvidersSection } from '#/kosong/provider/provider';
|
||||
import { ProtocolSchema } from '#/kosong/protocol/protocol';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// `providers` — kosong's provider registry (types: `kosong/provider/provider`)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const PROVIDERS_SECTION = 'providers';
|
||||
|
||||
export const DEFAULT_PROVIDER_SECTION = 'defaultProvider';
|
||||
|
||||
export const ENV_MODEL_PROVIDER_KEY = '__kimi_env__';
|
||||
|
||||
export const ProviderTypeSchema = z.string();
|
||||
|
||||
export const OAuthRefSchema = z.object({
|
||||
storage: z.enum(['file', 'keyring']),
|
||||
key: z.string().min(1),
|
||||
oauthHost: z.string().min(1).optional(),
|
||||
});
|
||||
|
||||
export const ModelSourceSchema = z.enum(['static', 'discover', 'oauth-catalog']);
|
||||
|
||||
const StringRecordSchema = z.record(z.string(), z.string());
|
||||
|
||||
export const ProviderConfigSchema = z.object({
|
||||
modelSource: ModelSourceSchema.optional(),
|
||||
|
||||
baseUrl: z.string().optional(),
|
||||
customHeaders: StringRecordSchema.optional(),
|
||||
defaultModel: z.string().optional(),
|
||||
|
||||
type: ProviderTypeSchema.optional(),
|
||||
apiKey: z.string().optional(),
|
||||
oauth: OAuthRefSchema.optional(),
|
||||
env: StringRecordSchema.optional(),
|
||||
source: z.record(z.string(), z.unknown()).optional(),
|
||||
});
|
||||
|
||||
export const ProvidersSectionSchema = z.record(z.string(), ProviderConfigSchema);
|
||||
|
||||
// Compile-time pins: the schemas must stay in lockstep with kosong's
|
||||
// hand-written types (drift in either direction fails typecheck).
|
||||
type _AssertOAuthRef = AssertExact<Equal<z.infer<typeof OAuthRefSchema>, OAuthRef>>;
|
||||
type _AssertProviderConfig = AssertExact<
|
||||
Equal<z.infer<typeof ProviderConfigSchema>, ProviderConfig>
|
||||
>;
|
||||
type _AssertProvidersSection = AssertExact<
|
||||
Equal<z.infer<typeof ProvidersSectionSchema>, ProvidersSection>
|
||||
>;
|
||||
|
||||
// The `KIMI_MODEL_PROVIDER_TYPE` / `KIMI_MODEL_API_KEY` / `KIMI_MODEL_BASE_URL`
|
||||
// environment bindings synthesize the reserved `__kimi_env__` provider entry.
|
||||
export const providersEnvBindings = envBindings(ProvidersSectionSchema, {
|
||||
[ENV_MODEL_PROVIDER_KEY]: envBindings(ProviderConfigSchema, {
|
||||
apiKey: 'KIMI_MODEL_API_KEY',
|
||||
type: 'KIMI_MODEL_PROVIDER_TYPE',
|
||||
baseUrl: 'KIMI_MODEL_BASE_URL',
|
||||
}),
|
||||
});
|
||||
|
||||
export const stripProvidersEnv: ConfigStripEnv<Record<string, unknown>> = (value) => {
|
||||
if (value === undefined || value === null || typeof value !== 'object') return value;
|
||||
if (!(ENV_MODEL_PROVIDER_KEY in value)) return value;
|
||||
const out = { ...value };
|
||||
delete out[ENV_MODEL_PROVIDER_KEY];
|
||||
return out;
|
||||
};
|
||||
|
||||
export const providersFromToml = (rawSnake: unknown): unknown => {
|
||||
if (!isPlainObject(rawSnake)) return rawSnake;
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [name, entry] of Object.entries(rawSnake)) {
|
||||
out[name] = isPlainObject(entry) ? providerEntryFromToml(entry) : entry;
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
function providerEntryFromToml(data: Record<string, unknown>): Record<string, unknown> {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(data)) {
|
||||
const targetKey = snakeToCamel(key);
|
||||
if (targetKey === 'oauth') {
|
||||
out[targetKey] = isPlainObject(value) ? transformPlainObject(value) : value;
|
||||
} else if (targetKey === 'env' || targetKey === 'customHeaders') {
|
||||
out[targetKey] = isPlainObject(value) ? cloneRecord(value) : value;
|
||||
} else {
|
||||
out[targetKey] = value;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export const providersToToml = (value: unknown, rawSnake: unknown): unknown => {
|
||||
if (!isPlainObject(value)) return value;
|
||||
const rawSub = cloneRecord(rawSnake);
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [name, entry] of Object.entries(value)) {
|
||||
out[name] = isPlainObject(entry) ? providerEntryToToml(entry, rawSub[name]) : entry;
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
function providerEntryToToml(
|
||||
provider: Record<string, unknown>,
|
||||
rawProvider: unknown,
|
||||
): Record<string, unknown> {
|
||||
const out = cloneRecord(rawProvider);
|
||||
for (const [key, value] of Object.entries(provider)) {
|
||||
if (key === 'oauth' && isPlainObject(value)) {
|
||||
out[camelToSnake(key)] = plainObjectToToml(value, undefined);
|
||||
} else if ((key === 'env' || key === 'customHeaders') && value !== undefined) {
|
||||
out[camelToSnake(key)] = cloneRecord(value);
|
||||
} else {
|
||||
setDefined(out, camelToSnake(key), value);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
registerConfigSection(PROVIDERS_SECTION, ProvidersSectionSchema, {
|
||||
defaultValue: {},
|
||||
env: providersEnvBindings,
|
||||
stripEnv: stripProvidersEnv,
|
||||
fromToml: providersFromToml,
|
||||
toToml: providersToToml,
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// `models` — kosong's model registry (types: `kosong/model/model`)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const MODELS_SECTION = 'models';
|
||||
|
||||
/**
|
||||
* The global default-model pointer: a single model id from `[models.*]` used
|
||||
* whenever a call site does not name a model explicitly. Cross-domain by
|
||||
* nature — written by `IModelCatalog.setDefaultModel` and the OAuth login /
|
||||
* refresh flows (`app/auth`), read by runtime resolution fallbacks. The sole
|
||||
* owner of the key constant lives here; every consumer imports it.
|
||||
*/
|
||||
export const DEFAULT_MODEL_SECTION = 'defaultModel';
|
||||
|
||||
const ModelBaseSchema = z.object({
|
||||
providerId: z.string().optional(),
|
||||
|
||||
baseUrl: z.string().optional(),
|
||||
apiKey: z.string().optional(),
|
||||
oauth: OAuthRefSchema.optional(),
|
||||
|
||||
protocol: ProtocolSchema.optional(),
|
||||
|
||||
name: z.string().optional(),
|
||||
aliases: z.array(z.string()).optional(),
|
||||
|
||||
provider: z.string().optional(),
|
||||
model: z.string().optional(),
|
||||
maxContextSize: z.number().int().min(1).optional(),
|
||||
maxInputSize: z.number().int().min(1).optional(),
|
||||
maxOutputSize: z.number().int().min(1).optional(),
|
||||
capabilities: z.array(z.string()).optional(),
|
||||
displayName: z.string().optional(),
|
||||
reasoningKey: z.string().optional(),
|
||||
adaptiveThinking: z.boolean().optional(),
|
||||
betaApi: z.boolean().optional(),
|
||||
supportEfforts: z.array(z.string()).optional(),
|
||||
defaultEffort: z.string().optional(),
|
||||
offEffort: z.string().optional(),
|
||||
});
|
||||
|
||||
export const ModelOverrideSchema = ModelBaseSchema.omit({
|
||||
providerId: true,
|
||||
baseUrl: true,
|
||||
apiKey: true,
|
||||
oauth: true,
|
||||
protocol: true,
|
||||
name: true,
|
||||
aliases: true,
|
||||
provider: true,
|
||||
model: true,
|
||||
betaApi: true,
|
||||
}).partial();
|
||||
|
||||
export const ModelRecordSchema = ModelBaseSchema.extend({
|
||||
overrides: ModelOverrideSchema.optional(),
|
||||
}).passthrough();
|
||||
|
||||
export const ModelsSectionSchema = z.record(z.string(), ModelRecordSchema);
|
||||
|
||||
// Compile-time pins: the schemas must stay in lockstep with kosong's
|
||||
// hand-written types (drift in either direction fails typecheck).
|
||||
type _AssertModelOverride = AssertExact<
|
||||
Equal<z.infer<typeof ModelOverrideSchema>, ModelOverride>
|
||||
>;
|
||||
type _AssertModelRecord = AssertExact<Equal<z.infer<typeof ModelRecordSchema>, ModelRecord>>;
|
||||
type _AssertModelsSection = AssertExact<
|
||||
Equal<z.infer<typeof ModelsSectionSchema>, ModelsSection>
|
||||
>;
|
||||
|
||||
// The transforms preserve user-defined model ids (record keys) while
|
||||
// converting each id's fields.
|
||||
export const modelsFromToml = (rawSnake: unknown): unknown => {
|
||||
if (!isPlainObject(rawSnake)) return rawSnake;
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [id, entry] of Object.entries(rawSnake)) {
|
||||
if (!isPlainObject(entry)) {
|
||||
out[id] = entry;
|
||||
continue;
|
||||
}
|
||||
const converted = transformPlainObject(entry);
|
||||
if (isPlainObject(converted['overrides'])) {
|
||||
converted['overrides'] = transformPlainObject(converted['overrides']);
|
||||
}
|
||||
out[id] = converted;
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
export const modelsToToml = (value: unknown, rawSnake: unknown): unknown => {
|
||||
if (!isPlainObject(value)) return value;
|
||||
const rawSub = cloneRecord(rawSnake);
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [id, entry] of Object.entries(value)) {
|
||||
if (!isPlainObject(entry)) {
|
||||
out[id] = entry;
|
||||
continue;
|
||||
}
|
||||
const rawEntry = cloneRecord(rawSub[id]);
|
||||
const converted: Record<string, unknown> = {};
|
||||
for (const [key, field] of Object.entries(entry)) {
|
||||
if (key === 'capabilities' && Array.isArray(field)) {
|
||||
converted[camelToSnake(key)] = [...field];
|
||||
} else if (key === 'overrides' && isPlainObject(field)) {
|
||||
converted['overrides'] = modelOverridesToToml(field, rawEntry['overrides']);
|
||||
} else {
|
||||
setDefined(converted, camelToSnake(key), field);
|
||||
}
|
||||
}
|
||||
out[id] = { ...rawEntry, ...converted };
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
function modelOverridesToToml(
|
||||
overrides: Record<string, unknown>,
|
||||
rawSnake: unknown,
|
||||
): Record<string, unknown> {
|
||||
const out = cloneRecord(rawSnake);
|
||||
for (const [key, value] of Object.entries(overrides)) {
|
||||
if (key === 'capabilities' && Array.isArray(value)) {
|
||||
out[camelToSnake(key)] = [...value];
|
||||
} else {
|
||||
setDefined(out, camelToSnake(key), value);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
registerConfigSection(MODELS_SECTION, ModelsSectionSchema, {
|
||||
defaultValue: {},
|
||||
fromToml: modelsFromToml,
|
||||
toToml: modelsToToml,
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// `thinking` — thinking defaults (type: `kosong/model/thinking`)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const THINKING_SECTION = 'thinking';
|
||||
|
||||
export const ThinkingConfigSchema = z.object({
|
||||
enabled: z.boolean().optional(),
|
||||
effort: z.string().optional(),
|
||||
forcedEffort: z.string().optional(),
|
||||
keep: z.string().optional(),
|
||||
});
|
||||
|
||||
// Compile-time pin: the schema must stay in lockstep with kosong's
|
||||
// hand-written `ThinkingConfig` type (drift in either direction fails
|
||||
// typecheck).
|
||||
type _AssertThinkingConfig = AssertExact<
|
||||
Equal<z.infer<typeof ThinkingConfigSchema>, ThinkingConfig>
|
||||
>;
|
||||
|
||||
// The `KIMI_MODEL_THINKING_EFFORT` env binding is an env-only forcedEffort
|
||||
// override; the strip keeps it out of `config.toml`.
|
||||
export const thinkingEnvBindings = envBindings(ThinkingConfigSchema, {
|
||||
forcedEffort: 'KIMI_MODEL_THINKING_EFFORT',
|
||||
});
|
||||
|
||||
export const stripThinkingEnv: ConfigStripEnv<ThinkingConfig> = (value) => {
|
||||
const result = { ...value };
|
||||
delete result.forcedEffort;
|
||||
return result;
|
||||
};
|
||||
|
||||
registerConfigSection(THINKING_SECTION, ThinkingConfigSchema, {
|
||||
env: thinkingEnvBindings,
|
||||
stripEnv: stripThinkingEnv,
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// `modelCatalog` — provider-model catalog auto-refresh cadence (no kosong type)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Read by the kap-server model-catalog refresh scheduler to decide the
|
||||
// refresh interval and whether to refresh once on start. Env vars
|
||||
// (`KIMI_CODE_MODEL_CATALOG_REFRESH_INTERVAL_MS`,
|
||||
// `KIMI_CODE_MODEL_CATALOG_REFRESH_ON_START`) override these values at the
|
||||
// scheduler edge.
|
||||
export const MODEL_CATALOG_SECTION = 'modelCatalog';
|
||||
|
||||
export const ModelCatalogConfigSchema = z.object({
|
||||
refreshIntervalMs: z.number().int().min(0).optional(),
|
||||
refreshOnStart: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export type ModelCatalogConfig = z.infer<typeof ModelCatalogConfigSchema>;
|
||||
|
||||
registerConfigSection(MODEL_CATALOG_SECTION, ModelCatalogConfigSchema);
|
||||
|
|
@ -1,14 +1,16 @@
|
|||
/**
|
||||
* `kosong/model` domain (L2) — `IProviderDiscoveryService`: remote model
|
||||
* `kosongConfig` domain (L3) — `IProviderDiscoveryService`: remote model
|
||||
* discovery and config sync.
|
||||
*
|
||||
* Refreshes the `[models.*]` / `[providers.*]` configuration from what each
|
||||
* provider actually serves (managed OAuth catalogs, open platforms, custom
|
||||
* registries) through the shared `@moonshot-ai/kimi-code-oauth` orchestrator,
|
||||
* and publishes `event.model_catalog.changed` on change. This is a WRITE
|
||||
* path (external world → config), deliberately separate from the read-only
|
||||
* `IModelCatalog` materialization/query surface (config → runtime). The
|
||||
* OAuth-only managed-provider refresh additionally lives in `auth`
|
||||
* applies the result to kosong's in-memory registries (the persistence
|
||||
* bridge writes it back to config), and publishes
|
||||
* `event.model_catalog.changed` on change. This is a WRITE path (external
|
||||
* world → kosong → config), deliberately separate from the read-only
|
||||
* `IModelCatalog` materialization/query surface. The OAuth-only
|
||||
* managed-provider refresh additionally lives in `auth`
|
||||
* (`IOAuthService.refreshOAuthProviderModels`).
|
||||
*/
|
||||
|
||||
|
|
@ -1,11 +1,12 @@
|
|||
/**
|
||||
* `kosong/model` domain (L2) — `IProviderDiscoveryService` implementation.
|
||||
* `kosongConfig` domain (L3) — `IProviderDiscoveryService` implementation.
|
||||
*
|
||||
* Owns the all-provider model refresh: delegates to the shared
|
||||
* `@moonshot-ai/kimi-code-oauth` orchestrator (managed OAuth + open
|
||||
* platforms + custom registries), writes the discovered providers/models
|
||||
* back through `config`, and publishes `event.model_catalog.changed` on
|
||||
* change. Bound at App scope.
|
||||
* platforms + custom registries), applies the discovered providers/models
|
||||
* to kosong's in-memory registries (the persistence bridge writes them back
|
||||
* to config), and publishes `event.model_catalog.changed` on change. Bound
|
||||
* at App scope.
|
||||
*
|
||||
* `modelSource: 'static'` short-circuits refresh: a provider whose effective
|
||||
* model source is `static` (config-declared, or declared by its vendor
|
||||
|
|
@ -16,6 +17,16 @@
|
|||
* and merged back verbatim on every write, so the orchestrator can neither
|
||||
* refresh them nor drop them (or a default model pointing at them).
|
||||
*
|
||||
* Two write-path details preserve the legacy semantics exactly:
|
||||
* - Registry replaces preserve the entries the orchestrator could not see:
|
||||
* the static exclusion AND the config-file-external entries (the
|
||||
* env-synthesized `__kimi_env__` slice), which the orchestrator's
|
||||
* user-value view does not contain.
|
||||
* - `defaultModel` / `thinking` stay direct `config.replace` writes (like
|
||||
* the OAuth flows): the env overlay may pin the runtime default to the
|
||||
* env-synthesized model, and only the config effective view knows that —
|
||||
* the bridge then syncs the effective pointer into the registry.
|
||||
*
|
||||
* Credential detection goes through the provider-definition registry
|
||||
* (`resolveProviderEndpoint` against the provider's config env bag), not a
|
||||
* per-protocol env table.
|
||||
|
|
@ -33,32 +44,30 @@ import { InstantiationType } from '#/_base/di/extensions';
|
|||
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
|
||||
import { Error2 } from '#/_base/errors/errors';
|
||||
import { IOAuthService } from '#/app/auth/auth';
|
||||
import { IConfigService } from '#/app/config/config';
|
||||
import { IEventService } from '#/app/event/event';
|
||||
|
||||
import { IConfigService } from '../../app/config/config';
|
||||
import { ModelCatalogErrors } from './errors';
|
||||
import { IHostRequestHeaders } from './hostRequestHeaders';
|
||||
import {
|
||||
DEFAULT_MODEL_SECTION,
|
||||
IModelService,
|
||||
MODELS_SECTION,
|
||||
type ModelRecord,
|
||||
} from './model';
|
||||
import { THINKING_SECTION } from './thinking';
|
||||
|
||||
import {
|
||||
IProviderDiscoveryService,
|
||||
type RefreshProviderModelsOptions,
|
||||
type RefreshProviderModelsResponse,
|
||||
} from './discovery';
|
||||
import { ModelCatalogErrors } from '#/kosong/model/errors';
|
||||
import { IHostRequestHeaders } from '#/kosong/model/hostRequestHeaders';
|
||||
import { IModelService, type ModelRecord } from '#/kosong/model/model';
|
||||
import {
|
||||
IProviderService,
|
||||
type ModelSource,
|
||||
type OAuthRef,
|
||||
type ProviderConfig,
|
||||
} from '#/kosong/provider/provider';
|
||||
import { getProviderDefinition } from '#/kosong/provider/providerDefinition';
|
||||
|
||||
import {
|
||||
DEFAULT_MODEL_SECTION,
|
||||
MODELS_SECTION,
|
||||
PROVIDERS_SECTION,
|
||||
} from '../provider/provider';
|
||||
import { getProviderDefinition } from '../provider/providerDefinition';
|
||||
THINKING_SECTION,
|
||||
} from './configSection';
|
||||
import {
|
||||
IProviderDiscoveryService,
|
||||
type RefreshProviderModelsOptions,
|
||||
type RefreshProviderModelsResponse,
|
||||
} from './discovery';
|
||||
|
||||
/**
|
||||
* Statically-sourced providers (and their bound models) hidden from the
|
||||
|
|
@ -203,6 +212,23 @@ export class ProviderDiscoveryService implements IProviderDiscoveryService {
|
|||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The registry entries the orchestrator's user-value view cannot see (the
|
||||
* env-synthesized slice): preserved verbatim across every registry
|
||||
* replace, or a refresh would drop the runtime env model/provider.
|
||||
*/
|
||||
private syntheticProviders(
|
||||
userProviders: Readonly<Record<string, unknown>>,
|
||||
): Record<string, ProviderConfig> {
|
||||
return withoutKeys(this.providerService.list(), userProviders);
|
||||
}
|
||||
|
||||
private syntheticModels(
|
||||
userModels: Readonly<Record<string, unknown>>,
|
||||
): Record<string, ModelRecord> {
|
||||
return withoutKeys(this.modelService.list(), userModels);
|
||||
}
|
||||
|
||||
private async removeProviderForRefresh(providerId: string): Promise<ManagedKimiConfigShape> {
|
||||
const current = this.readUserConfigShape();
|
||||
const providers = current.providers as Record<string, ProviderConfig>;
|
||||
|
|
@ -213,8 +239,11 @@ export class ProviderDiscoveryService implements IProviderDiscoveryService {
|
|||
const restModels = Object.fromEntries(
|
||||
Object.entries(models).filter(([, record]) => record.provider !== providerId),
|
||||
);
|
||||
await this.config.replace(PROVIDERS_SECTION, restProviders);
|
||||
await this.config.replace(MODELS_SECTION, restModels);
|
||||
await this.providerService.replaceAll({
|
||||
...this.syntheticProviders(providers),
|
||||
...restProviders,
|
||||
});
|
||||
await this.modelService.replaceAll({ ...this.syntheticModels(models), ...restModels });
|
||||
return {
|
||||
...current,
|
||||
providers: restProviders,
|
||||
|
|
@ -226,14 +255,27 @@ export class ProviderDiscoveryService implements IProviderDiscoveryService {
|
|||
patch: ManagedKimiConfigShape,
|
||||
exclusion: StaticExclusion,
|
||||
): Promise<ManagedKimiConfigShape> {
|
||||
const userProviders =
|
||||
this.config.inspect<Record<string, ProviderConfig>>(PROVIDERS_SECTION).userValue ?? {};
|
||||
const userModels =
|
||||
this.config.inspect<Record<string, ModelRecord>>(MODELS_SECTION).userValue ?? {};
|
||||
if (patch.providers !== undefined) {
|
||||
await this.config.replace(PROVIDERS_SECTION, {
|
||||
await this.providerService.replaceAll({
|
||||
...this.syntheticProviders(userProviders),
|
||||
...exclusion.providers,
|
||||
...patch.providers,
|
||||
});
|
||||
}
|
||||
if (patch.models !== undefined) {
|
||||
await this.config.replace(MODELS_SECTION, { ...exclusion.models, ...patch.models });
|
||||
await this.modelService.replaceAll({
|
||||
...this.syntheticModels(userModels),
|
||||
...exclusion.models,
|
||||
// The orchestrator's alias shape is a structural superset of
|
||||
// ModelRecord at runtime (its protocol union additionally allows
|
||||
// vendor spellings the records never actually carry); the legacy
|
||||
// config.write path took `unknown`, so cast here.
|
||||
...(patch.models as Record<string, ModelRecord>),
|
||||
});
|
||||
}
|
||||
// The refresh orchestrator always sends all four keys, so key presence is
|
||||
// the write intent and an explicit `undefined` means CLEAR, not "leave
|
||||
|
|
@ -246,6 +288,11 @@ export class ProviderDiscoveryService implements IProviderDiscoveryService {
|
|||
// Exception: when the user's default points at a statically-sourced model
|
||||
// the orchestrator could not see, its clamp/restore logic would silently
|
||||
// clear or re-point the selection (and its thinking) — restore both.
|
||||
//
|
||||
// `defaultModel` / `thinking` go through config directly (not the
|
||||
// registry): the env overlay may pin the runtime default, and only the
|
||||
// config effective view knows — the bridge syncs the effective pointer
|
||||
// into the registry afterwards.
|
||||
const restoreDefault = exclusion.defaultModel !== undefined;
|
||||
if ('defaultModel' in patch) {
|
||||
await this.config.replace(
|
||||
|
|
@ -259,7 +306,31 @@ export class ProviderDiscoveryService implements IProviderDiscoveryService {
|
|||
restoreDefault ? exclusion.thinking : patch.thinking,
|
||||
);
|
||||
}
|
||||
return this.readUserConfigShape();
|
||||
// The writes above landed in the registries / config; compute the
|
||||
// post-patch shape in memory (re-reading config would race the bridge's
|
||||
// asynchronous persist of the registry changes).
|
||||
return {
|
||||
providers:
|
||||
patch.providers !== undefined
|
||||
? ({ ...exclusion.providers, ...patch.providers } as ManagedKimiConfigShape['providers'])
|
||||
: (userProviders as ManagedKimiConfigShape['providers']),
|
||||
models:
|
||||
patch.models !== undefined
|
||||
? ({ ...exclusion.models, ...patch.models } as ManagedKimiConfigShape['models'])
|
||||
: (userModels as ManagedKimiConfigShape['models']),
|
||||
defaultModel:
|
||||
'defaultModel' in patch
|
||||
? restoreDefault
|
||||
? exclusion.defaultModel
|
||||
: patch.defaultModel
|
||||
: this.config.inspect<string>(DEFAULT_MODEL_SECTION).userValue,
|
||||
thinking:
|
||||
'thinking' in patch
|
||||
? restoreDefault
|
||||
? exclusion.thinking
|
||||
: patch.thinking
|
||||
: this.config.inspect<ManagedKimiConfigShape['thinking']>(THINKING_SECTION).userValue,
|
||||
};
|
||||
}
|
||||
|
||||
private async resolveOAuthToken(
|
||||
|
|
@ -307,5 +378,5 @@ registerScopedService(
|
|||
IProviderDiscoveryService,
|
||||
ProviderDiscoveryService,
|
||||
InstantiationType.Eager,
|
||||
'modelCatalog',
|
||||
'kosongConfig',
|
||||
);
|
||||
|
|
@ -1,18 +1,18 @@
|
|||
/**
|
||||
* `kosong/model` domain (L2) — `KIMI_MODEL_*` effective-config overlay.
|
||||
* `kosongConfig` domain (L3) — `KIMI_MODEL_*` effective-config overlay.
|
||||
*
|
||||
* When `KIMI_MODEL_NAME` is set, synthesizes one model id (bound to the
|
||||
* reserved `__kimi_env__` provider owned by the `provider` domain) from the
|
||||
* reserved `__kimi_env__` provider whose schema kosong owns) from the
|
||||
* `KIMI_MODEL_*` environment variables and overlays it onto the resolved
|
||||
* `effective` config: the reserved model entry, `defaultModel`, and the request
|
||||
* `modelOverrides`. The overlay is applied ONLY to the in-memory `effective`
|
||||
* view; its `strip` removes the synthesized values on the write path so they
|
||||
* never reach `config.toml`. Self-registered into `IConfigRegistry` at module
|
||||
* load (see `configOverlayContributions.ts`), so the `config` domain never
|
||||
* imports this domain's model semantics, and so the overlay takes effect even
|
||||
* when `ModelService` is never instantiated.
|
||||
* imports kosong semantics, and so the overlay takes effect even when the
|
||||
* kosong registry services are never instantiated.
|
||||
*
|
||||
* The env provider's default `baseUrl` is resolved through the
|
||||
* The env provider's default `baseUrl` is resolved through kosong's
|
||||
* provider-definition registry (`resolveProviderEndpoint` against the same
|
||||
* env the overlay reads), not from a hardcoded vendor table — for Kimi that
|
||||
* is the `KIMI_BASE_URL` → `https://api.moonshot.ai/v1` chain declared by the
|
||||
|
|
@ -22,11 +22,12 @@
|
|||
import { parseBooleanEnv } from '#/_base/utils/env';
|
||||
import { Error2 } from '#/_base/errors/errors';
|
||||
|
||||
import type { ConfigEffectiveOverlay } from '../../app/config/config';
|
||||
import { registerConfigOverlay } from '../../app/config/configOverlayContributions';
|
||||
import { ConfigErrors } from '../../app/config/errors';
|
||||
import { ENV_MODEL_PROVIDER_KEY } from '../provider/provider';
|
||||
import { resolveProviderEndpoint } from '../provider/providerDefinition';
|
||||
import type { ConfigEffectiveOverlay } from '#/app/config/config';
|
||||
import { registerConfigOverlay } from '#/app/config/configOverlayContributions';
|
||||
import { CONFIG_INVALID_ERROR_CODE } from '#/kosong/contract/errors';
|
||||
import { resolveProviderEndpoint } from '#/kosong/provider/providerDefinition';
|
||||
|
||||
import { ENV_MODEL_PROVIDER_KEY } from './configSection';
|
||||
|
||||
export const ENV_MODEL_ALIAS_KEY = '__kimi_env_model__';
|
||||
|
||||
|
|
@ -40,7 +41,7 @@ function trimmed(value: string | undefined): string | undefined {
|
|||
}
|
||||
|
||||
function fail(message: string): never {
|
||||
throw new Error2(ConfigErrors.codes.CONFIG_INVALID, message);
|
||||
throw new Error2(CONFIG_INVALID_ERROR_CODE, message);
|
||||
}
|
||||
|
||||
function parsePositiveInt(raw: string, varName: string): number {
|
||||
|
|
@ -141,9 +142,9 @@ export const kimiModelEnvOverlay: ConfigEffectiveOverlay = {
|
|||
|
||||
const maxOutputRaw = trimmed(getEnv('KIMI_MODEL_MAX_OUTPUT_SIZE'));
|
||||
const maxOutputSize =
|
||||
maxOutputRaw !== undefined
|
||||
? parsePositiveInt(maxOutputRaw, 'KIMI_MODEL_MAX_OUTPUT_SIZE')
|
||||
: undefined;
|
||||
maxOutputRaw === undefined
|
||||
? undefined
|
||||
: parsePositiveInt(maxOutputRaw, 'KIMI_MODEL_MAX_OUTPUT_SIZE');
|
||||
const capabilities = parseCapabilities(getEnv('KIMI_MODEL_CAPABILITIES')) ?? DEFAULT_CAPABILITIES;
|
||||
const displayName = trimmed(getEnv('KIMI_MODEL_DISPLAY_NAME'));
|
||||
const reasoningKey = trimmed(getEnv('KIMI_MODEL_REASONING_KEY'));
|
||||
31
packages/agent-core-v2/src/app/kosongConfig/kosongConfig.ts
Normal file
31
packages/agent-core-v2/src/app/kosongConfig/kosongConfig.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
/**
|
||||
* `kosongConfig` domain (L3) — the kosong persistence bridge contract.
|
||||
*
|
||||
* `IKosongConfigService` is the two-way sync between the config service
|
||||
* (persistence) and kosong's in-memory provider/model registries:
|
||||
*
|
||||
* - **Startup / config → kosong**: once config is ready, the registries are
|
||||
* hydrated from the effective config view; later config section changes
|
||||
* (TOML edits, `config.reload`, direct `config.set/replace` writes such as
|
||||
* the OAuth flows) are pushed into kosong the same way.
|
||||
* - **kosong → config**: mutations that land in kosong (klient `addProvider`,
|
||||
* discovery refresh results, default-pointer changes) fire kosong change
|
||||
* events, which the bridge persists back through `config.replace`.
|
||||
*
|
||||
* Kosong itself never sees the config service — this bridge is the only
|
||||
* component that knows both sides. Bound at App scope; instantiated by the
|
||||
* composition root (`bootstrap`) so hydration is guaranteed before any
|
||||
* consumer can await the kosong registries' `ready`.
|
||||
*/
|
||||
|
||||
import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation';
|
||||
|
||||
export interface IKosongConfigService {
|
||||
readonly _serviceBrand: undefined;
|
||||
|
||||
/** Resolves once the initial config → kosong hydration has completed. */
|
||||
readonly ready: Promise<void>;
|
||||
}
|
||||
|
||||
export const IKosongConfigService: ServiceIdentifier<IKosongConfigService> =
|
||||
createDecorator<IKosongConfigService>('kosongConfigService');
|
||||
|
|
@ -0,0 +1,254 @@
|
|||
/**
|
||||
* `kosongConfig` domain (L3) — `IKosongConfigService` implementation.
|
||||
*
|
||||
* The two-way persistence bridge between `IConfigService` and kosong's
|
||||
* in-memory provider/model registries. See `kosongConfig.ts` for the
|
||||
* contract-level description.
|
||||
*
|
||||
* Both sync directions are idempotent by deep comparison, which is what
|
||||
* makes the loop terminate without any reentrancy flags:
|
||||
*
|
||||
* - config → kosong: the registries' writes are silent when the value is
|
||||
* equal, so a config-originated push never echoes back as a persist.
|
||||
* - kosong → config: the persist handlers skip the write when the config
|
||||
* value already matches the registry state (the case for every
|
||||
* config-originated push), so a persist never echoes back as a sync.
|
||||
* - env-pinned pointers: a registry-originated default-pointer write lands
|
||||
* in the user layer even when an effective overlay pins the section
|
||||
* (`KIMI_MODEL_NAME` → `defaultModel`); the bridge then re-asserts the
|
||||
* pinned effective value into the registry, so a registry read can never
|
||||
* diverge from the effective config view.
|
||||
*
|
||||
* Persists are serialized through a promise chain so rapid mutation bursts
|
||||
* reach the disk in event order, and each persist is hooked into the
|
||||
* registry's change event through `waitUntil` — so an awaited registry
|
||||
* mutation (`providers.set(...)`, `models.setDefaultModel(...)`, ...) only
|
||||
* resolves once the write has actually landed in config. A failed persist is
|
||||
* retried with backoff before the failure is logged; the mutation's caller is
|
||||
* never rejected (the in-memory change stands either way).
|
||||
*/
|
||||
|
||||
import { InstantiationType } from '#/_base/di/extensions';
|
||||
import { Disposable } from '#/_base/di/lifecycle';
|
||||
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
|
||||
import { ILogService } from '#/_base/log/log';
|
||||
import { retryBackoffDelays, sleepForRetry } from '#/_base/utils/retry';
|
||||
|
||||
import { type ConfigSectionChangedEvent, IConfigService } from '#/app/config/config';
|
||||
import { describeUnknownError } from '#/app/config/configPure';
|
||||
import { deepEqual } from '#/app/config/sectionDiff';
|
||||
import { IModelService, type ModelsSection } from '#/kosong/model/model';
|
||||
import { IProviderService, type ProvidersSection } from '#/kosong/provider/provider';
|
||||
|
||||
import { IKosongConfigService } from './kosongConfig';
|
||||
import {
|
||||
DEFAULT_MODEL_SECTION,
|
||||
DEFAULT_PROVIDER_SECTION,
|
||||
MODELS_SECTION,
|
||||
PROVIDERS_SECTION,
|
||||
} from './configSection';
|
||||
|
||||
/** Persist attempts per write; see `replaceWithRetry` for why this stays small. */
|
||||
const PERSIST_MAX_ATTEMPTS = 3;
|
||||
|
||||
export class KosongConfigService extends Disposable implements IKosongConfigService {
|
||||
declare readonly _serviceBrand: undefined;
|
||||
|
||||
readonly ready: Promise<void>;
|
||||
|
||||
private persistChain: Promise<void> = Promise.resolve();
|
||||
|
||||
constructor(
|
||||
@IConfigService private readonly config: IConfigService,
|
||||
@IProviderService private readonly providers: IProviderService,
|
||||
@IModelService private readonly models: IModelService,
|
||||
@ILogService private readonly log: ILogService,
|
||||
) {
|
||||
super();
|
||||
this.ready = this.initialize();
|
||||
// The composition root instantiates the bridge without awaiting it; log
|
||||
// initialization failures instead of surfacing an unhandled rejection.
|
||||
void this.ready.catch((error) => {
|
||||
this.log.warn('kosong config bridge initialization failed', {
|
||||
error: describeUnknownError(error),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private async initialize(): Promise<void> {
|
||||
await this.config.ready;
|
||||
// Hydrate first, subscribe after: the initial load comes FROM config, so
|
||||
// it must not echo back as a persist (the equality guards would catch it
|
||||
// anyway, but skipping the round trip keeps startup quiet).
|
||||
this.providers.loadAll(
|
||||
this.config.get<ProvidersSection>(PROVIDERS_SECTION) ?? {},
|
||||
this.config.get<string>(DEFAULT_PROVIDER_SECTION),
|
||||
);
|
||||
this.models.loadAll(
|
||||
this.config.get<ModelsSection>(MODELS_SECTION) ?? {},
|
||||
this.config.get<string>(DEFAULT_MODEL_SECTION),
|
||||
);
|
||||
this._register(this.config.onDidSectionChange((e) => this.onConfigSectionChanged(e)));
|
||||
// The guards mirror the ones inside the persist tasks: skipping the echo
|
||||
// here (instead of registering a no-op `waitUntil`) keeps config-originated
|
||||
// syncs fully synchronous for every other listener, even while the persist
|
||||
// chain is busy with a retrying write.
|
||||
this._register(
|
||||
this.providers.onDidChangeProviders((e) => {
|
||||
if (
|
||||
deepEqual(this.config.get<ProvidersSection>(PROVIDERS_SECTION) ?? {}, this.providers.list())
|
||||
) {
|
||||
return;
|
||||
}
|
||||
e.waitUntil(this.enqueuePersistProviders());
|
||||
}),
|
||||
);
|
||||
this._register(
|
||||
this.providers.onDidChangeDefaultProvider((e) => {
|
||||
if (this.config.get<string>(DEFAULT_PROVIDER_SECTION) === e.id) return;
|
||||
e.waitUntil(this.enqueuePersistDefaultPointer(DEFAULT_PROVIDER_SECTION, e.id));
|
||||
}),
|
||||
);
|
||||
this._register(
|
||||
this.models.onDidChangeModels((e) => {
|
||||
if (deepEqual(this.config.get<ModelsSection>(MODELS_SECTION) ?? {}, this.models.list())) {
|
||||
return;
|
||||
}
|
||||
e.waitUntil(this.enqueuePersistModels());
|
||||
}),
|
||||
);
|
||||
this._register(
|
||||
this.models.onDidChangeDefaultModel((e) => {
|
||||
if (this.config.get<string>(DEFAULT_MODEL_SECTION) === e.id) return;
|
||||
e.waitUntil(this.enqueuePersistDefaultPointer(DEFAULT_MODEL_SECTION, e.id));
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// config → kosong
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private onConfigSectionChanged(e: ConfigSectionChangedEvent): void {
|
||||
switch (e.domain) {
|
||||
case PROVIDERS_SECTION:
|
||||
this.providers.loadAll(
|
||||
(e.value as ProvidersSection | undefined) ?? {},
|
||||
// Sync the RECORDS only: the default pointer has its own domain
|
||||
// event below. Re-applying config's pointer here would resurrect a
|
||||
// stale value over a newer registry pointer — e.g. the cleared
|
||||
// pointer of a default-provider delete, whose own persist has not
|
||||
// run yet — and the two-way sync would livelock.
|
||||
this.providers.getDefaultProvider(),
|
||||
);
|
||||
break;
|
||||
case MODELS_SECTION:
|
||||
this.models.loadAll(
|
||||
(e.value as ModelsSection | undefined) ?? {},
|
||||
// See PROVIDERS_SECTION above: the pointer syncs through its own
|
||||
// DEFAULT_MODEL_SECTION event.
|
||||
this.models.getDefaultModel(),
|
||||
);
|
||||
break;
|
||||
case DEFAULT_PROVIDER_SECTION:
|
||||
void this.providers
|
||||
.setDefaultProvider(e.value as string | undefined)
|
||||
.catch((error) => this.logPersistFailure(error));
|
||||
break;
|
||||
case DEFAULT_MODEL_SECTION:
|
||||
void this.models
|
||||
.setDefaultModel(e.value as string | undefined)
|
||||
.catch((error) => this.logPersistFailure(error));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// kosong → config
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private enqueuePersistProviders(): Promise<void> {
|
||||
return this.enqueue(async () => {
|
||||
const next = this.providers.list();
|
||||
if (deepEqual(this.config.get<ProvidersSection>(PROVIDERS_SECTION) ?? {}, next)) return;
|
||||
await this.replaceWithRetry(PROVIDERS_SECTION, next);
|
||||
});
|
||||
}
|
||||
|
||||
private enqueuePersistModels(): Promise<void> {
|
||||
return this.enqueue(async () => {
|
||||
const next = this.models.list();
|
||||
if (deepEqual(this.config.get<ModelsSection>(MODELS_SECTION) ?? {}, next)) return;
|
||||
await this.replaceWithRetry(MODELS_SECTION, next);
|
||||
});
|
||||
}
|
||||
|
||||
private enqueuePersistDefaultPointer(domain: string, value: string | undefined): Promise<void> {
|
||||
return this.enqueue(async () => {
|
||||
if (this.config.get<string>(domain) === value) return;
|
||||
await this.replaceWithRetry(domain, value);
|
||||
// An effective overlay may pin the section (e.g. `KIMI_MODEL_NAME`
|
||||
// pins `defaultModel` to the reserved env model): the write then lands
|
||||
// only in the user layer — the effective value does not move and no
|
||||
// change event fires — while the registry keeps the unpinned value.
|
||||
// Re-assert the effective value so a registry read can never diverge
|
||||
// from the pinned view; the re-assert's own change event no-ops back
|
||||
// into this persist through the equality guard above.
|
||||
const effective = this.config.get<string>(domain);
|
||||
if (effective === value) return;
|
||||
// Fire-and-forget: the re-assert's persist is a guaranteed no-op, and
|
||||
// awaiting it from inside the persist chain would deadlock — its own
|
||||
// `waitUntil` would queue behind this very task.
|
||||
if (domain === DEFAULT_PROVIDER_SECTION) {
|
||||
void this.providers
|
||||
.setDefaultProvider(effective)
|
||||
.catch((error) => this.logPersistFailure(error));
|
||||
} else if (domain === DEFAULT_MODEL_SECTION) {
|
||||
void this.models
|
||||
.setDefaultModel(effective)
|
||||
.catch((error) => this.logPersistFailure(error));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Disk-write failures are rare and usually transient, so a failed persist is
|
||||
* retried with backoff before giving up to the log (via the chain's catch).
|
||||
* The retry budget stays small on purpose: the persist chain serializes
|
||||
* every mutation's await behind it, so a multi-minute budget would stall
|
||||
* all callers instead of just logging the failure.
|
||||
*/
|
||||
private async replaceWithRetry(domain: string, value: unknown): Promise<void> {
|
||||
const delays = retryBackoffDelays(PERSIST_MAX_ATTEMPTS);
|
||||
for (let attempt = 0; ; attempt += 1) {
|
||||
try {
|
||||
await this.config.replace(domain, value);
|
||||
return;
|
||||
} catch (error) {
|
||||
const delay = delays[attempt];
|
||||
if (delay === undefined) throw error;
|
||||
await sleepForRetry(delay);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private enqueue(task: () => Promise<void>): Promise<void> {
|
||||
// The chain itself always recovers so one failed task cannot poison the
|
||||
// persists queued behind it; the returned promise is what the registry's
|
||||
// `waitUntil` (and thereby the mutation's caller) observes.
|
||||
this.persistChain = this.persistChain.then(task).catch((error) => this.logPersistFailure(error));
|
||||
return this.persistChain;
|
||||
}
|
||||
|
||||
private logPersistFailure(error: unknown): void {
|
||||
this.log.warn('kosong config persist failed', { error: describeUnknownError(error) });
|
||||
}
|
||||
}
|
||||
|
||||
registerScopedService(
|
||||
LifecycleScope.App,
|
||||
IKosongConfigService,
|
||||
KosongConfigService,
|
||||
InstantiationType.Eager,
|
||||
'kosongConfig',
|
||||
);
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
/**
|
||||
* `kosongConfig` domain (L3) — `IModelOAuthTokens` implementation.
|
||||
*
|
||||
* Delegates kosong's OAuth token port to `IOAuthService` and owns the
|
||||
* `auth.login_required` error contract (the code is registered by
|
||||
* `app/auth/errors`): kosong's model catalog only sees the port.
|
||||
*/
|
||||
|
||||
import { InstantiationType } from '#/_base/di/extensions';
|
||||
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
|
||||
import { Error2 } from '#/_base/errors/errors';
|
||||
|
||||
import { IOAuthService } from '#/app/auth/auth';
|
||||
import { AuthErrors } from '#/app/auth/errors';
|
||||
import { nonEmpty } from '#/kosong/model/modelAuth';
|
||||
import { IModelOAuthTokens } from '#/kosong/model/modelOAuth';
|
||||
import type { OAuthRef } from '#/kosong/provider/provider';
|
||||
|
||||
export class ModelOAuthTokenAdapter implements IModelOAuthTokens {
|
||||
declare readonly _serviceBrand: undefined;
|
||||
|
||||
constructor(@IOAuthService private readonly oauth: IOAuthService) {}
|
||||
|
||||
async hasCachedAccessToken(provider: string, oauthRef: OAuthRef): Promise<boolean> {
|
||||
try {
|
||||
const token = await this.oauth.getCachedAccessToken(provider, oauthRef);
|
||||
return nonEmpty(token) !== undefined;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async getAccessToken(
|
||||
provider: string,
|
||||
oauthRef: OAuthRef,
|
||||
options?: { readonly force?: boolean },
|
||||
): Promise<string> {
|
||||
const tokenProvider = this.oauth.resolveTokenProvider(provider, oauthRef);
|
||||
if (tokenProvider === undefined) throw loginRequired(provider);
|
||||
const token = await tokenProvider.getAccessToken(
|
||||
options?.force === true ? { force: true } : undefined,
|
||||
);
|
||||
if (token.trim().length === 0) throw loginRequired(provider);
|
||||
return token;
|
||||
}
|
||||
}
|
||||
|
||||
function loginRequired(providerKey: string): Error2 {
|
||||
return new Error2(
|
||||
AuthErrors.codes.AUTH_LOGIN_REQUIRED,
|
||||
`OAuth provider "${providerKey}" requires login before it can be used.`,
|
||||
);
|
||||
}
|
||||
|
||||
registerScopedService(
|
||||
LifecycleScope.App,
|
||||
IModelOAuthTokens,
|
||||
ModelOAuthTokenAdapter,
|
||||
InstantiationType.Eager,
|
||||
'kosongConfig',
|
||||
);
|
||||
|
|
@ -83,7 +83,7 @@ export * from '#/session/sessionToolPolicy/sessionToolPolicy';
|
|||
export * from '#/session/sessionToolPolicy/sessionToolPolicyService';
|
||||
export * from '#/app/config/config';
|
||||
export * from '#/app/config/configService';
|
||||
import '#/kosong/provider/configSection';
|
||||
import '#/app/kosongConfig/configSection';
|
||||
export * from '#/kosong/provider/provider';
|
||||
export * from '#/kosong/provider/providerService';
|
||||
export * from '#/kosong/provider/providerDefinition';
|
||||
|
|
@ -94,9 +94,7 @@ export * from '#/kosong/protocol/errors';
|
|||
export * from '#/kosong/protocol/protocol';
|
||||
export * from '#/kosong/protocol/protocolBase';
|
||||
export * from '#/kosong/protocol/protocolTrait';
|
||||
import '#/kosong/model/configSection';
|
||||
import '#/kosong/model/envOverlay';
|
||||
import '#/kosong/model/thinking';
|
||||
import '#/app/kosongConfig/envOverlay';
|
||||
export * from '#/kosong/model/completionBudget';
|
||||
export * from '#/kosong/model/hostRequestHeaders';
|
||||
export * from '#/kosong/model/model';
|
||||
|
|
@ -107,13 +105,20 @@ export * from '#/kosong/model/catalog';
|
|||
export * from '#/kosong/model/catalogService';
|
||||
export * from '#/kosong/model/modelRequester';
|
||||
import '#/kosong/model/errors';
|
||||
import '#/kosong/model/discoveryConfigSection';
|
||||
// `ModelCatalogConfig` / `MODEL_CATALOG_SECTION` live in the configSection
|
||||
// side-effect module but the edge (kap-server's refresh scheduler) consumes
|
||||
// them from the package root — re-export here.
|
||||
export * from '#/kosong/model/discoveryConfigSection';
|
||||
export * from '#/kosong/model/discovery';
|
||||
export * from '#/kosong/model/discoveryService';
|
||||
export {
|
||||
MODEL_CATALOG_SECTION,
|
||||
ModelCatalogConfigSchema,
|
||||
type ModelCatalogConfig,
|
||||
} from '#/app/kosongConfig/configSection';
|
||||
export * from '#/app/kosongConfig/kosongConfig';
|
||||
export * from '#/app/kosongConfig/kosongConfigService';
|
||||
export * from '#/kosong/model/modelOAuth';
|
||||
export * from '#/app/kosongConfig/oauthTokenAdapter';
|
||||
export * from '#/app/kosongConfig/discovery';
|
||||
export * from '#/app/kosongConfig/discoveryService';
|
||||
// kosong wire composition roots — importing these modules registers the four
|
||||
// protocol bases and every provider definition (kimi + the canonical vendor
|
||||
// endpoints); without them the adapter registry stays empty.
|
||||
|
|
|
|||
|
|
@ -18,6 +18,16 @@
|
|||
|
||||
import type { FinishReason } from './provider';
|
||||
|
||||
/**
|
||||
* Wire error code for invalid model/provider configuration (`config.invalid`).
|
||||
* The code string is a wire contract matched downstream (protocol event
|
||||
* schemas, clients), so it is declared here in the L0 contract; the error
|
||||
* registry entry is owned by the `config` domain's error module, which
|
||||
* registers this constant. Kosong code throws it without registering, keeping
|
||||
* a single registry owner.
|
||||
*/
|
||||
export const CONFIG_INVALID_ERROR_CODE = 'config.invalid';
|
||||
|
||||
export class ChatProviderError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
|
|
|
|||
|
|
@ -21,10 +21,9 @@
|
|||
* Enumeration (`listModels` / `listProviders` / `getProvider`) projects the
|
||||
* SAME materialization `get` serves into the wire catalog shapes below, so
|
||||
* the management surface can never drift from what the runtime resolves.
|
||||
* `setDefaultModel` writes the global default-model pointer
|
||||
* (`DEFAULT_MODEL_SECTION`); it is the catalog's only write, validated
|
||||
* against materialization so an unresolvable model can never become the
|
||||
* default.
|
||||
* `setDefaultModel` writes the global default-model pointer (through
|
||||
* `IModelService`); it is the catalog's only write, validated against
|
||||
* materialization so an unresolvable model can never become the default.
|
||||
*
|
||||
* The catalog caches assembled Models by id and invalidates on the
|
||||
* model/provider config-change events. Tests that mutate config
|
||||
|
|
@ -272,8 +271,8 @@ export interface IModelCatalog {
|
|||
/** One provider by id; throws `provider.not_found` when unconfigured. */
|
||||
getProvider(providerId: string): Promise<ProviderCatalogItem>;
|
||||
/**
|
||||
* The catalog's only write: point the global default (`DEFAULT_MODEL_SECTION`)
|
||||
* at `modelId`. Unknown ids throw `model.not_found`; ids that fail
|
||||
* The catalog's only write: point the global default-model pointer at
|
||||
* `modelId`. Unknown ids throw `model.not_found`; ids that fail
|
||||
* materialization are rejected with the materialization error.
|
||||
*/
|
||||
setDefaultModel(modelId: string): Promise<SetDefaultModelResponse>;
|
||||
|
|
|
|||
|
|
@ -40,9 +40,9 @@
|
|||
* config-only projection for models that fail to materialize, so broken
|
||||
* config stays visible); `listProviders` / `getProvider` project the
|
||||
* provider registry plus credential state. `setDefaultModel` writes the
|
||||
* global default-model pointer (`DEFAULT_MODEL_SECTION`) after a
|
||||
* global default-model pointer (through `IModelService`) after a
|
||||
* materialization gate — the catalog's only write. The remote-discovery
|
||||
* refresh lives in `kosong/provider/discovery`, not here.
|
||||
* refresh lives in `app/kosongConfig`, not here.
|
||||
*/
|
||||
|
||||
import { parseKimiCodeCustomHeaders } from '@moonshot-ai/kimi-code-oauth';
|
||||
|
|
@ -51,8 +51,6 @@ import { InstantiationType } from '#/_base/di/extensions';
|
|||
import { Disposable } from '#/_base/di/lifecycle';
|
||||
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
|
||||
import { Error2 } from '#/_base/errors/errors';
|
||||
import { IOAuthService } from '#/app/auth/auth';
|
||||
import { AuthErrors } from '#/app/auth/errors';
|
||||
import type { ModelCapability } from '#/kosong/contract/capability';
|
||||
import type { ProviderRequestAuth } from '#/kosong/contract/provider';
|
||||
import type { TokenUsage } from '#/kosong/contract/usage';
|
||||
|
|
@ -63,15 +61,13 @@ import {
|
|||
type ProtocolProviderOptions,
|
||||
} from '#/kosong/protocol/protocol';
|
||||
|
||||
import { IConfigService } from '../../app/config/config';
|
||||
import { ConfigErrors } from '../../app/config/errors';
|
||||
import { CONFIG_INVALID_ERROR_CODE } from '#/kosong/contract/errors';
|
||||
import {
|
||||
LATEST_OPUS_PROFILE,
|
||||
matchKnownAnthropicModelProfile,
|
||||
matchUnknownClaudeProfile,
|
||||
} from '../provider/bases/anthropic/anthropic-profile';
|
||||
import {
|
||||
DEFAULT_PROVIDER_SECTION,
|
||||
IProviderService,
|
||||
type ProviderConfig,
|
||||
} from '../provider/provider';
|
||||
|
|
@ -105,13 +101,14 @@ import {
|
|||
ResolutionTraceCollector,
|
||||
TRACE,
|
||||
} from './inspection';
|
||||
import { DEFAULT_MODEL_SECTION, IModelService, type ModelRecord } from './model';
|
||||
import { IModelService, type ModelRecord } from './model';
|
||||
import {
|
||||
deriveProviderId,
|
||||
effectiveModelConfig,
|
||||
nonEmpty,
|
||||
resolveModelAuthMaterial,
|
||||
} from './modelAuth';
|
||||
import { IModelOAuthTokens } from './modelOAuth';
|
||||
import type { ResolvedModelAuthMaterial } from './model.types';
|
||||
import type { ModelRequester } from './modelRequester';
|
||||
import { ModelRequesterImpl } from './modelRequesterImpl';
|
||||
|
|
@ -134,10 +131,9 @@ export class ModelCatalog extends Disposable implements IModelCatalog {
|
|||
private readonly cache = new Map<string, CatalogEntry>();
|
||||
|
||||
constructor(
|
||||
@IConfigService private readonly config: IConfigService,
|
||||
@IProviderService private readonly providers: IProviderService,
|
||||
@IModelService private readonly models: IModelService,
|
||||
@IOAuthService private readonly oauth: IOAuthService,
|
||||
@IModelOAuthTokens private readonly oauth: IModelOAuthTokens,
|
||||
@IProtocolAdapterRegistry
|
||||
private readonly protocolRegistry: IProtocolAdapterRegistry,
|
||||
@IHostRequestHeaders private readonly hostRequestHeaders: IHostRequestHeaders,
|
||||
|
|
@ -248,7 +244,7 @@ export class ModelCatalog extends Disposable implements IModelCatalog {
|
|||
async listProviders(): Promise<readonly ProviderCatalogItem[]> {
|
||||
const providers = this.providers.list();
|
||||
const models = this.models.list();
|
||||
const globalDefaultModel = this.config.get<string>(DEFAULT_MODEL_SECTION);
|
||||
const globalDefaultModel = this.models.getDefaultModel();
|
||||
const out: ProviderCatalogItem[] = [];
|
||||
for (const [providerId, provider] of Object.entries(providers)) {
|
||||
out.push(await this.toCatalogProvider(providerId, provider, models, globalDefaultModel));
|
||||
|
|
@ -265,7 +261,7 @@ export class ModelCatalog extends Disposable implements IModelCatalog {
|
|||
);
|
||||
}
|
||||
const models = this.models.list();
|
||||
const globalDefaultModel = this.config.get<string>(DEFAULT_MODEL_SECTION);
|
||||
const globalDefaultModel = this.models.getDefaultModel();
|
||||
return this.toCatalogProvider(providerId, provider, models, globalDefaultModel);
|
||||
}
|
||||
|
||||
|
|
@ -280,7 +276,7 @@ export class ModelCatalog extends Disposable implements IModelCatalog {
|
|||
// Materialization gate: a model that cannot resolve (dangling provider
|
||||
// reference, conflicting credentials, ...) must not become the default.
|
||||
const model = this.get(modelId);
|
||||
await this.config.set(DEFAULT_MODEL_SECTION, modelId);
|
||||
await this.models.setDefaultModel(modelId);
|
||||
return {
|
||||
default_model: modelId,
|
||||
model: toProtocolModel(model, record, this.providerTypeOf(record)),
|
||||
|
|
@ -309,17 +305,12 @@ export class ModelCatalog extends Disposable implements IModelCatalog {
|
|||
|
||||
private async hasCachedToken(providerId: string, provider: ProviderConfig): Promise<boolean> {
|
||||
if (provider.oauth === undefined) return false;
|
||||
try {
|
||||
const token = await this.oauth.getCachedAccessToken(providerId, provider.oauth);
|
||||
return nonEmpty(token) !== undefined;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
return this.oauth.hasCachedAccessToken(providerId, provider.oauth);
|
||||
}
|
||||
|
||||
private providerTypeOf(record: ModelRecord): string | undefined {
|
||||
const providerId =
|
||||
record.providerId ?? record.provider ?? this.config.get<string>(DEFAULT_PROVIDER_SECTION);
|
||||
record.providerId ?? record.provider ?? this.providers.getDefaultProvider();
|
||||
return this.providers.get(providerId ?? '')?.type ?? record.protocol;
|
||||
}
|
||||
|
||||
|
|
@ -327,7 +318,7 @@ export class ModelCatalog extends Disposable implements IModelCatalog {
|
|||
const configuredModel = this.models.get(id);
|
||||
if (configuredModel === undefined) {
|
||||
throw new Error2(
|
||||
ConfigErrors.codes.CONFIG_INVALID,
|
||||
CONFIG_INVALID_ERROR_CODE,
|
||||
`Model "${id}" is not configured in config.toml.`,
|
||||
);
|
||||
}
|
||||
|
|
@ -376,13 +367,13 @@ export class ModelCatalog extends Disposable implements IModelCatalog {
|
|||
: rawBaseUrl;
|
||||
if (wireName === undefined) {
|
||||
throw new Error2(
|
||||
ConfigErrors.codes.CONFIG_INVALID,
|
||||
CONFIG_INVALID_ERROR_CODE,
|
||||
`Model "${id}" must define a wire-facing name in config.toml.`,
|
||||
);
|
||||
}
|
||||
if (model.maxContextSize === undefined) {
|
||||
throw new Error2(
|
||||
ConfigErrors.codes.CONFIG_INVALID,
|
||||
CONFIG_INVALID_ERROR_CODE,
|
||||
`Model "${id}" must define a positive max_context_size in config.toml.`,
|
||||
);
|
||||
}
|
||||
|
|
@ -449,7 +440,7 @@ export class ModelCatalog extends Disposable implements IModelCatalog {
|
|||
readonly resolvedBaseUrl: string | undefined;
|
||||
} {
|
||||
const providerId =
|
||||
model.providerId ?? model.provider ?? this.config.get<string>('defaultProvider');
|
||||
model.providerId ?? model.provider ?? this.providers.getDefaultProvider();
|
||||
if (providerId !== undefined) {
|
||||
trace.record('provider', {
|
||||
kind: 'config',
|
||||
|
|
@ -464,7 +455,7 @@ export class ModelCatalog extends Disposable implements IModelCatalog {
|
|||
const providerConfig = this.providers.get(providerId);
|
||||
if (providerConfig === undefined) {
|
||||
throw new Error2(
|
||||
ConfigErrors.codes.CONFIG_INVALID,
|
||||
CONFIG_INVALID_ERROR_CODE,
|
||||
`Provider "${providerId}" referenced by model "${id}" is not configured.`,
|
||||
);
|
||||
}
|
||||
|
|
@ -505,7 +496,7 @@ export class ModelCatalog extends Disposable implements IModelCatalog {
|
|||
const modelBaseUrl = nonEmpty(model.baseUrl);
|
||||
if (modelBaseUrl === undefined) {
|
||||
throw new Error2(
|
||||
ConfigErrors.codes.CONFIG_INVALID,
|
||||
CONFIG_INVALID_ERROR_CODE,
|
||||
`Model "${id}" must set either providerId or baseUrl in config.toml.`,
|
||||
);
|
||||
}
|
||||
|
|
@ -559,7 +550,7 @@ export class ModelCatalog extends Disposable implements IModelCatalog {
|
|||
}
|
||||
}
|
||||
throw new Error2(
|
||||
ConfigErrors.codes.CONFIG_INVALID,
|
||||
CONFIG_INVALID_ERROR_CODE,
|
||||
`Model "${id}" must declare a wire protocol (config: models.<id>.protocol).`,
|
||||
);
|
||||
}
|
||||
|
|
@ -571,22 +562,13 @@ export class ModelCatalog extends Disposable implements IModelCatalog {
|
|||
if (auth.oauth !== undefined) {
|
||||
const oauthRef = auth.oauth;
|
||||
const providerKey = auth.oauthProviderKey ?? providerName;
|
||||
const oauthService = this.oauth;
|
||||
const loginRequired = (cause?: unknown): Error2 =>
|
||||
new Error2(
|
||||
AuthErrors.codes.AUTH_LOGIN_REQUIRED,
|
||||
`OAuth provider "${providerKey}" requires login before it can be used.`,
|
||||
cause === undefined ? undefined : { cause },
|
||||
);
|
||||
const tokens = this.oauth;
|
||||
return {
|
||||
canRefresh: true,
|
||||
async getAuth(options): Promise<ProviderRequestAuth | undefined> {
|
||||
const tokenProvider = oauthService.resolveTokenProvider(providerKey, oauthRef);
|
||||
if (tokenProvider === undefined) throw loginRequired();
|
||||
const apiKey = await tokenProvider.getAccessToken(
|
||||
options?.force === true ? { force: true } : undefined,
|
||||
);
|
||||
if (apiKey.trim().length === 0) throw loginRequired();
|
||||
const apiKey = await tokens.getAccessToken(providerKey, oauthRef, {
|
||||
force: options?.force === true,
|
||||
});
|
||||
return { apiKey };
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,89 +0,0 @@
|
|||
/**
|
||||
* `kosong/model` domain (L2) — `models` config-section TOML transforms.
|
||||
*
|
||||
* Snake_case ↔ camelCase transforms that preserve user-defined model ids
|
||||
* (record keys) while converting each id's fields. Self-registered at module
|
||||
* load via `registerConfigSection`, so the `config` domain never imports this
|
||||
* domain's types.
|
||||
*
|
||||
* Side-effect module: production gets it from the `src/index.ts`
|
||||
* side-effect block; tests import it on demand. This module is the sole
|
||||
* owner of the section — the legacy `app/model/configSection` is gone.
|
||||
*
|
||||
* Note: the `app/config` imports below are deliberately RELATIVE paths — see
|
||||
* `modelService.ts` for the rationale.
|
||||
*/
|
||||
|
||||
import { registerConfigSection } from '../../app/config/configSectionContributions';
|
||||
import {
|
||||
camelToSnake,
|
||||
cloneRecord,
|
||||
isPlainObject,
|
||||
setDefined,
|
||||
transformPlainObject,
|
||||
} from '../../app/config/toml';
|
||||
|
||||
import { MODELS_SECTION, ModelsSectionSchema } from './model';
|
||||
|
||||
export const modelsFromToml = (rawSnake: unknown): unknown => {
|
||||
if (!isPlainObject(rawSnake)) return rawSnake;
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [id, entry] of Object.entries(rawSnake)) {
|
||||
if (!isPlainObject(entry)) {
|
||||
out[id] = entry;
|
||||
continue;
|
||||
}
|
||||
const converted = transformPlainObject(entry);
|
||||
if (isPlainObject(converted['overrides'])) {
|
||||
converted['overrides'] = transformPlainObject(converted['overrides']);
|
||||
}
|
||||
out[id] = converted;
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
export const modelsToToml = (value: unknown, rawSnake: unknown): unknown => {
|
||||
if (!isPlainObject(value)) return value;
|
||||
const rawSub = cloneRecord(rawSnake);
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [id, entry] of Object.entries(value)) {
|
||||
if (!isPlainObject(entry)) {
|
||||
out[id] = entry;
|
||||
continue;
|
||||
}
|
||||
const rawEntry = cloneRecord(rawSub[id]);
|
||||
const converted: Record<string, unknown> = {};
|
||||
for (const [key, field] of Object.entries(entry)) {
|
||||
if (key === 'capabilities' && Array.isArray(field)) {
|
||||
converted[camelToSnake(key)] = [...field];
|
||||
} else if (key === 'overrides' && isPlainObject(field)) {
|
||||
converted['overrides'] = modelOverridesToToml(field, rawEntry['overrides']);
|
||||
} else {
|
||||
setDefined(converted, camelToSnake(key), field);
|
||||
}
|
||||
}
|
||||
out[id] = { ...rawEntry, ...converted };
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
function modelOverridesToToml(
|
||||
overrides: Record<string, unknown>,
|
||||
rawSnake: unknown,
|
||||
): Record<string, unknown> {
|
||||
const out = cloneRecord(rawSnake);
|
||||
for (const [key, value] of Object.entries(overrides)) {
|
||||
if (key === 'capabilities' && Array.isArray(value)) {
|
||||
out[camelToSnake(key)] = [...value];
|
||||
} else {
|
||||
setDefined(out, camelToSnake(key), value);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
registerConfigSection(MODELS_SECTION, ModelsSectionSchema, {
|
||||
defaultValue: {},
|
||||
fromToml: modelsFromToml,
|
||||
toToml: modelsToToml,
|
||||
});
|
||||
|
|
@ -1,33 +0,0 @@
|
|||
/**
|
||||
* `kosong/model` domain (L2) — `modelCatalog` config-section schema.
|
||||
*
|
||||
* Owns the `[modelCatalog]` configuration section (provider-model catalog
|
||||
* auto-refresh cadence). Self-registered at module load via
|
||||
* `registerConfigSection`, mirroring the per-domain `configSection.ts`
|
||||
* convention, so the `config` domain never imports this domain's types.
|
||||
*
|
||||
* Read by the kap-server model-catalog refresh scheduler to decide the
|
||||
* refresh interval and whether to refresh once on start. Env vars
|
||||
* (`KIMI_CODE_MODEL_CATALOG_REFRESH_INTERVAL_MS`,
|
||||
* `KIMI_CODE_MODEL_CATALOG_REFRESH_ON_START`) override these values at the
|
||||
* scheduler edge.
|
||||
*
|
||||
* Side-effect module: production gets it from the `src/index.ts`
|
||||
* side-effect block; tests import it on demand. This module is the sole
|
||||
* owner of the section — the legacy `app/modelCatalog/configSection` is gone.
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
import { registerConfigSection } from '../../app/config/configSectionContributions';
|
||||
|
||||
export const MODEL_CATALOG_SECTION = 'modelCatalog';
|
||||
|
||||
export const ModelCatalogConfigSchema = z.object({
|
||||
refreshIntervalMs: z.number().int().min(0).optional(),
|
||||
refreshOnStart: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export type ModelCatalogConfig = z.infer<typeof ModelCatalogConfigSchema>;
|
||||
|
||||
registerConfigSection(MODEL_CATALOG_SECTION, ModelCatalogConfigSchema);
|
||||
|
|
@ -1,9 +1,13 @@
|
|||
/**
|
||||
* `kosong/model` domain (L2) — model configuration registry contract.
|
||||
*
|
||||
* Owns the `ModelRecord` config record (id → resolution recipe) and the
|
||||
* `models` config section; exposes CRUD and persists through `config`. App-
|
||||
* scoped — model configuration is global and shared across sessions.
|
||||
* Owns the `ModelRecord` config record type (id → resolution recipe) and the
|
||||
* in-memory model registry contract. App-scoped — model configuration is
|
||||
* global and shared across sessions. Kosong has no persistence — it defines
|
||||
* types only: the `models` / `defaultModel` section constants, the zod
|
||||
* schemas (compile-time pinned to these types), and the TOML transforms all
|
||||
* live in the persistence wrapper (`app/kosongConfig/configSection`).
|
||||
* Persisting mutations is the upper layer's job, not this domain's.
|
||||
*
|
||||
* Two configuration paths are supported:
|
||||
* - **Structured**: `providerId` references an entry in `[providers.*]`.
|
||||
|
|
@ -25,74 +29,70 @@
|
|||
* `type`, never as a protocol).
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation';
|
||||
import type { Event } from '#/_base/event';
|
||||
import { ProtocolSchema } from '#/kosong/protocol/protocol';
|
||||
import type { Event, IWaitUntil } from '#/_base/event';
|
||||
import type { Protocol } from '#/kosong/protocol/protocol';
|
||||
|
||||
import { OAuthRefSchema } from '../provider/provider';
|
||||
|
||||
export const MODELS_SECTION = 'models';
|
||||
import type { OAuthRef } from '../provider/provider';
|
||||
|
||||
/**
|
||||
* The global default-model pointer: a single model id from `[models.*]` used
|
||||
* whenever a call site does not name a model explicitly. Cross-domain by
|
||||
* nature — written by `IModelCatalog.setDefaultModel` and the OAuth login /
|
||||
* refresh flows (`app/auth`), read by runtime resolution fallbacks. The sole
|
||||
* owner of the key constant lives here; every consumer imports it.
|
||||
* The per-model `overrides` sub-record: the tunable subset of the base
|
||||
* fields, all optional (runtime-fetched catalog metadata may refine the
|
||||
* configured record without touching identity/auth fields).
|
||||
*/
|
||||
export const DEFAULT_MODEL_SECTION = 'defaultModel';
|
||||
export interface ModelOverride {
|
||||
maxContextSize?: number;
|
||||
maxInputSize?: number;
|
||||
maxOutputSize?: number;
|
||||
capabilities?: string[];
|
||||
displayName?: string;
|
||||
reasoningKey?: string;
|
||||
adaptiveThinking?: boolean;
|
||||
supportEfforts?: string[];
|
||||
defaultEffort?: string;
|
||||
offEffort?: string;
|
||||
}
|
||||
|
||||
const ModelBaseSchema = z.object({
|
||||
providerId: z.string().optional(),
|
||||
/**
|
||||
* The persisted section schema is a passthrough object: unknown fields
|
||||
* survive parsing (lossless config round-trip), so the type carries the
|
||||
* catch-all index signature too. Declared as a single flat interface (not a
|
||||
* `Fields & { [key: string]: unknown }` intersection) so the compile-time
|
||||
* schema ≡ type pin in `app/kosongConfig/configSection` compares equal
|
||||
* to zod's flattened passthrough inference.
|
||||
*/
|
||||
export interface ModelRecord {
|
||||
providerId?: string;
|
||||
|
||||
baseUrl: z.string().optional(),
|
||||
apiKey: z.string().optional(),
|
||||
oauth: OAuthRefSchema.optional(),
|
||||
baseUrl?: string;
|
||||
apiKey?: string;
|
||||
oauth?: OAuthRef;
|
||||
|
||||
protocol: ProtocolSchema.optional(),
|
||||
protocol?: Protocol;
|
||||
|
||||
name: z.string().optional(),
|
||||
aliases: z.array(z.string()).optional(),
|
||||
name?: string;
|
||||
aliases?: string[];
|
||||
|
||||
provider: z.string().optional(),
|
||||
model: z.string().optional(),
|
||||
maxContextSize: z.number().int().min(1).optional(),
|
||||
maxInputSize: z.number().int().min(1).optional(),
|
||||
maxOutputSize: z.number().int().min(1).optional(),
|
||||
capabilities: z.array(z.string()).optional(),
|
||||
displayName: z.string().optional(),
|
||||
reasoningKey: z.string().optional(),
|
||||
adaptiveThinking: z.boolean().optional(),
|
||||
betaApi: z.boolean().optional(),
|
||||
supportEfforts: z.array(z.string()).optional(),
|
||||
defaultEffort: z.string().optional(),
|
||||
offEffort: z.string().optional(),
|
||||
});
|
||||
provider?: string;
|
||||
model?: string;
|
||||
maxContextSize?: number;
|
||||
maxInputSize?: number;
|
||||
maxOutputSize?: number;
|
||||
capabilities?: string[];
|
||||
displayName?: string;
|
||||
reasoningKey?: string;
|
||||
adaptiveThinking?: boolean;
|
||||
betaApi?: boolean;
|
||||
supportEfforts?: string[];
|
||||
defaultEffort?: string;
|
||||
offEffort?: string;
|
||||
|
||||
export const ModelOverrideSchema = ModelBaseSchema.omit({
|
||||
providerId: true,
|
||||
baseUrl: true,
|
||||
apiKey: true,
|
||||
oauth: true,
|
||||
protocol: true,
|
||||
name: true,
|
||||
aliases: true,
|
||||
provider: true,
|
||||
model: true,
|
||||
betaApi: true,
|
||||
}).partial();
|
||||
overrides?: ModelOverride;
|
||||
|
||||
export const ModelRecordSchema = ModelBaseSchema.extend({
|
||||
overrides: ModelOverrideSchema.optional(),
|
||||
}).passthrough();
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export type ModelRecord = z.infer<typeof ModelRecordSchema>;
|
||||
|
||||
export const ModelsSectionSchema = z.record(z.string(), ModelRecordSchema);
|
||||
|
||||
export type ModelsSection = z.infer<typeof ModelsSectionSchema>;
|
||||
export type ModelsSection = Record<string, ModelRecord>;
|
||||
|
||||
export interface ModelsChangedEvent {
|
||||
readonly added: readonly string[];
|
||||
|
|
@ -100,14 +100,46 @@ export interface ModelsChangedEvent {
|
|||
readonly changed: readonly string[];
|
||||
}
|
||||
|
||||
export interface DefaultModelChangedEvent {
|
||||
readonly id: string | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* The in-memory model registry. Kosong owns the state and the change events;
|
||||
* persistence is the upper layer's concern (a bridge service hydrates the
|
||||
* registry via `loadAll` and subscribes to the change events to persist
|
||||
* them), so this domain never touches config storage itself.
|
||||
*
|
||||
* Mutations (`set` / `delete` / `replaceAll` / `setDefaultModel`) wait for
|
||||
* hydration (`ready`) so a caller can never race the initial load, and resolve
|
||||
* only after every change listener has finished the work it registered through
|
||||
* `waitUntil` — the persistence bridge participates this way, so an awaited
|
||||
* mutation means the write has also been persisted. Writes that land an equal
|
||||
* value are silent — no event fires — which is what makes the persistence
|
||||
* bridge's two-way sync terminate.
|
||||
*/
|
||||
export interface IModelService {
|
||||
readonly _serviceBrand: undefined;
|
||||
|
||||
readonly onDidChangeModels: Event<ModelsChangedEvent>;
|
||||
/** Resolves when the registry has been hydrated (the first `loadAll`). */
|
||||
readonly ready: Promise<void>;
|
||||
readonly onDidChangeModels: Event<ModelsChangedEvent & IWaitUntil>;
|
||||
/** Fires when the default-model pointer changes value (incl. clearing). */
|
||||
readonly onDidChangeDefaultModel: Event<DefaultModelChangedEvent & IWaitUntil>;
|
||||
get(id: string): ModelRecord | undefined;
|
||||
list(): Readonly<Record<string, ModelRecord>>;
|
||||
getDefaultModel(): string | undefined;
|
||||
set(id: string, model: ModelRecord): Promise<void>;
|
||||
delete(id: string): Promise<void>;
|
||||
/**
|
||||
* Bulk hydration by the persistence owner: replaces the whole registry and
|
||||
* the default-model pointer. The first call resolves `ready`; later calls
|
||||
* behave like a deep-equal-aware sync (only real diffs fire events).
|
||||
*/
|
||||
loadAll(models: ModelsSection, defaultModel: string | undefined): void;
|
||||
/** Replaces every model record; the default-model pointer is kept. */
|
||||
replaceAll(models: ModelsSection): Promise<void>;
|
||||
setDefaultModel(id: string | undefined): Promise<void>;
|
||||
}
|
||||
|
||||
// The decorator name matches the deleted legacy `app/model` contract
|
||||
|
|
|
|||
|
|
@ -21,9 +21,9 @@
|
|||
*/
|
||||
|
||||
import { Error2 } from '#/_base/errors/errors';
|
||||
import { CONFIG_INVALID_ERROR_CODE } from '#/kosong/contract/errors';
|
||||
import type { ResolutionTrace } from '#/kosong/contract/inspection';
|
||||
|
||||
import { ConfigErrors } from '../../app/config/errors';
|
||||
import {
|
||||
BUDGET_THINKING_EFFORTS,
|
||||
matchKnownAnthropicModelProfile,
|
||||
|
|
@ -171,7 +171,7 @@ export function nonEmpty(value: string | undefined): string | undefined {
|
|||
|
||||
function authConflictError(kind: string, name: string): Error2 {
|
||||
return new Error2(
|
||||
ConfigErrors.codes.CONFIG_INVALID,
|
||||
CONFIG_INVALID_ERROR_CODE,
|
||||
`${kind} "${name}" has both apiKey and oauth set in config.toml - they are mutually exclusive. Remove one.`,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
33
packages/agent-core-v2/src/kosong/model/modelOAuth.ts
Normal file
33
packages/agent-core-v2/src/kosong/model/modelOAuth.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
/**
|
||||
* `kosong/model` domain (L2) — the OAuth token port.
|
||||
*
|
||||
* Kosong needs OAuth tokens at model-assembly time: probing the cached
|
||||
* credential state (catalog listings) and building the refreshable request
|
||||
* auth closure. The port is owned here so kosong stays free of the
|
||||
* `app/auth` service; the implementation lives in the upper layer
|
||||
* (`app/kosongConfig/oauthTokenAdapter.ts`), which delegates to
|
||||
* `IOAuthService` and owns the `auth.login_required` error contract.
|
||||
*/
|
||||
|
||||
import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation';
|
||||
|
||||
import type { OAuthRef } from '../provider/provider';
|
||||
|
||||
export interface IModelOAuthTokens {
|
||||
readonly _serviceBrand: undefined;
|
||||
|
||||
/** Probes whether a usable cached token exists (never throws). */
|
||||
hasCachedAccessToken(provider: string, oauthRef: OAuthRef): Promise<boolean>;
|
||||
/**
|
||||
* Returns a usable access token, refreshing when needed. Throws
|
||||
* `auth.login_required` when the provider is not logged in.
|
||||
*/
|
||||
getAccessToken(
|
||||
provider: string,
|
||||
oauthRef: OAuthRef,
|
||||
options?: { readonly force?: boolean },
|
||||
): Promise<string>;
|
||||
}
|
||||
|
||||
export const IModelOAuthTokens: ServiceIdentifier<IModelOAuthTokens> =
|
||||
createDecorator<IModelOAuthTokens>('modelOAuthTokens');
|
||||
|
|
@ -1,70 +1,112 @@
|
|||
/**
|
||||
* `kosong/model` domain (L2) — `IModelService` implementation.
|
||||
*
|
||||
* Owns the in-memory view of the `models` config section, persists changes
|
||||
* through `config`, and forwards section changes as `onDidChangeModels`
|
||||
* (computed with the shared `sectionDiff.diffRecords`). The section schema
|
||||
* self-registers at module load via `configSection.ts`, and the `KIMI_MODEL_*`
|
||||
* effective overlay self-registers via `envOverlay.ts`. Bound at App scope.
|
||||
*
|
||||
* Note: the `app/config` imports below are deliberately RELATIVE paths — this
|
||||
* L2 domain may depend on the L2 config domain, and keeping the dependency
|
||||
* off the `#/` alias makes it visible at a glance.
|
||||
* The in-memory model registry plus the default-model pointer. Holds no
|
||||
* config dependency: the persistence bridge hydrates it via `loadAll` and
|
||||
* persists the change events it fires. Bound at App scope.
|
||||
*/
|
||||
|
||||
import { InstantiationType } from '#/_base/di/extensions';
|
||||
import { Disposable } from '#/_base/di/lifecycle';
|
||||
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
|
||||
import { Emitter, type Event } from '#/_base/event';
|
||||
import { AsyncEmitter, type Event, type IWaitUntil } from '#/_base/event';
|
||||
|
||||
import { deepEqual, diffRecords, isEmptyDiff } from '../recordDiff';
|
||||
|
||||
import { IConfigService } from '../../app/config/config';
|
||||
import { diffRecords } from '../../app/config/sectionDiff';
|
||||
import {
|
||||
type DefaultModelChangedEvent,
|
||||
IModelService,
|
||||
MODELS_SECTION,
|
||||
type ModelRecord,
|
||||
type ModelsChangedEvent,
|
||||
type ModelsSection,
|
||||
} from './model';
|
||||
|
||||
/** Registry mutations are not abortable; `fireAsync` still requires a signal. */
|
||||
const NO_ABORT = new AbortController().signal;
|
||||
|
||||
export class ModelService extends Disposable implements IModelService {
|
||||
declare readonly _serviceBrand: undefined;
|
||||
private readonly _onDidChangeModels = this._register(new Emitter<ModelsChangedEvent>());
|
||||
readonly onDidChangeModels: Event<ModelsChangedEvent> = this._onDidChangeModels.event;
|
||||
|
||||
constructor(@IConfigService private readonly config: IConfigService) {
|
||||
super();
|
||||
this._register(
|
||||
config.onDidChangeConfiguration((e) => {
|
||||
if (e.domain === MODELS_SECTION) {
|
||||
this._onDidChangeModels.fire(
|
||||
diffRecords<ModelRecord>(
|
||||
e.previousValue as ModelsSection | undefined,
|
||||
e.value as ModelsSection | undefined,
|
||||
),
|
||||
);
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
private models: Readonly<Record<string, ModelRecord>> = {};
|
||||
private defaultModel: string | undefined;
|
||||
private hydrated = false;
|
||||
private resolveReady!: () => void;
|
||||
readonly ready: Promise<void> = new Promise<void>((resolve) => {
|
||||
this.resolveReady = resolve;
|
||||
});
|
||||
|
||||
private readonly _onDidChangeModels = this._register(
|
||||
new AsyncEmitter<ModelsChangedEvent & IWaitUntil>(),
|
||||
);
|
||||
readonly onDidChangeModels: Event<ModelsChangedEvent & IWaitUntil> =
|
||||
this._onDidChangeModels.event;
|
||||
private readonly _onDidChangeDefaultModel = this._register(
|
||||
new AsyncEmitter<DefaultModelChangedEvent & IWaitUntil>(),
|
||||
);
|
||||
readonly onDidChangeDefaultModel: Event<DefaultModelChangedEvent & IWaitUntil> =
|
||||
this._onDidChangeDefaultModel.event;
|
||||
|
||||
get(id: string): ModelRecord | undefined {
|
||||
return this.config.get<ModelsSection>(MODELS_SECTION)?.[id];
|
||||
return this.models[id];
|
||||
}
|
||||
|
||||
list(): Readonly<Record<string, ModelRecord>> {
|
||||
return this.config.get<ModelsSection>(MODELS_SECTION) ?? {};
|
||||
return this.models;
|
||||
}
|
||||
|
||||
getDefaultModel(): string | undefined {
|
||||
return this.defaultModel;
|
||||
}
|
||||
|
||||
loadAll(models: ModelsSection, defaultModel: string | undefined): void {
|
||||
// Fire-and-forget on purpose: hydration has no persistence participant
|
||||
// yet, and `fireAsync` still invokes every listener synchronously up to
|
||||
// its own first await, so config-originated syncs keep their timing.
|
||||
void this.applyRecords(models);
|
||||
void this.applyDefaultModel(defaultModel);
|
||||
if (!this.hydrated) {
|
||||
this.hydrated = true;
|
||||
this.resolveReady();
|
||||
}
|
||||
}
|
||||
|
||||
async replaceAll(models: ModelsSection): Promise<void> {
|
||||
await this.ready;
|
||||
await this.applyRecords(models);
|
||||
}
|
||||
|
||||
async set(id: string, model: ModelRecord): Promise<void> {
|
||||
await this.config.set(MODELS_SECTION, { [id]: model });
|
||||
await this.ready;
|
||||
if (deepEqual(this.models[id], model)) return;
|
||||
await this.applyRecords({ ...this.models, [id]: model });
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
const current = this.config.get<ModelsSection>(MODELS_SECTION) ?? {};
|
||||
if (!(id in current)) return;
|
||||
const { [id]: _removed, ...rest } = current;
|
||||
await this.config.replace(MODELS_SECTION, rest);
|
||||
await this.ready;
|
||||
if (!(id in this.models)) return;
|
||||
const { [id]: _removed, ...rest } = this.models;
|
||||
await this.applyRecords(rest);
|
||||
}
|
||||
|
||||
async setDefaultModel(id: string | undefined): Promise<void> {
|
||||
await this.ready;
|
||||
await this.applyDefaultModel(id);
|
||||
}
|
||||
|
||||
private async applyRecords(next: Readonly<Record<string, ModelRecord>>): Promise<void> {
|
||||
const diff = diffRecords(this.models, next);
|
||||
if (isEmptyDiff(diff)) return;
|
||||
this.models = { ...next };
|
||||
// Awaiting delivery is what lets a mutation's caller rely on the
|
||||
// persistence bridge's `waitUntil` participation: the returned promise
|
||||
// only settles once the write has reached the config layer.
|
||||
await this._onDidChangeModels.fireAsync(diff, NO_ABORT);
|
||||
}
|
||||
|
||||
private async applyDefaultModel(id: string | undefined): Promise<void> {
|
||||
if (this.defaultModel === id) return;
|
||||
this.defaultModel = id;
|
||||
await this._onDidChangeDefaultModel.fireAsync({ id }, NO_ABORT);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,12 +3,11 @@
|
|||
*
|
||||
* Three kinds of knowledge live here, and nowhere else:
|
||||
*
|
||||
* 1. The `thinking` config section (`[thinking]`: enabled / effort / keep,
|
||||
* plus the env-only `KIMI_MODEL_THINKING_EFFORT` force override). The
|
||||
* section self-registers at module load — a side effect; production gets
|
||||
* it from the `src/index.ts` side-effect block and tests import this
|
||||
* module on demand. This module is the sole owner of the section — the
|
||||
* legacy `agent/profile/configSection` is gone.
|
||||
* 1. The `thinking` config-section type (`[thinking]`: enabled / effort /
|
||||
* keep, plus the env-only `forcedEffort` field). Kosong owns only the
|
||||
* type; the section constant, zod schema (compile-time pinned to the
|
||||
* type), registration, env binding, and write-path strip all live in the
|
||||
* persistence wrapper (`app/kosongConfig/configSection`).
|
||||
* 2. Effort/keep resolution: pure helpers that fold a requested effort, the
|
||||
* config defaults, and the model's declared thinking metadata into the
|
||||
* effective `ThinkingEffort`, and that resolve the thinking-keep value.
|
||||
|
|
@ -32,46 +31,23 @@
|
|||
* `kimiAnthropicTrait`.
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
import type { ThinkingEffort } from '#/kosong/contract/provider';
|
||||
import type { IProtocolAdapterRegistry, Protocol } from '#/kosong/protocol/protocol';
|
||||
|
||||
import { type ConfigStripEnv, envBindings } from '../../app/config/config';
|
||||
import { registerConfigSection } from '../../app/config/configSectionContributions';
|
||||
import { getProviderDefinitions } from '../provider/providerDefinition';
|
||||
|
||||
import type { ModelThinkingMetadata, ThinkingDefaults } from './model.types';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// `thinking` config section (side-effect registration)
|
||||
// `thinking` config-section type (constant + schema live in `app/kosongConfig`)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const THINKING_SECTION = 'thinking';
|
||||
|
||||
export const ThinkingConfigSchema = z.object({
|
||||
enabled: z.boolean().optional(),
|
||||
effort: z.string().optional(),
|
||||
forcedEffort: z.string().optional(),
|
||||
keep: z.string().optional(),
|
||||
});
|
||||
|
||||
export type ThinkingConfig = z.infer<typeof ThinkingConfigSchema>;
|
||||
|
||||
export const thinkingEnvBindings = envBindings(ThinkingConfigSchema, {
|
||||
forcedEffort: 'KIMI_MODEL_THINKING_EFFORT',
|
||||
});
|
||||
|
||||
export const stripThinkingEnv: ConfigStripEnv<ThinkingConfig> = (value) => {
|
||||
const result = { ...value };
|
||||
delete result.forcedEffort;
|
||||
return result;
|
||||
};
|
||||
|
||||
registerConfigSection(THINKING_SECTION, ThinkingConfigSchema, {
|
||||
env: thinkingEnvBindings,
|
||||
stripEnv: stripThinkingEnv,
|
||||
});
|
||||
export interface ThinkingConfig {
|
||||
enabled?: boolean;
|
||||
effort?: string;
|
||||
forcedEffort?: string;
|
||||
keep?: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Registry-driven vendor verdicts
|
||||
|
|
|
|||
|
|
@ -1,113 +0,0 @@
|
|||
/**
|
||||
* `kosong/provider` domain (L2) — `providers` config-section schema, env
|
||||
* bindings, and TOML transforms.
|
||||
*
|
||||
* Owns the `[providers.<name>]` configuration section: its schema (with the
|
||||
* free-form `type` field), the `KIMI_MODEL_PROVIDER_TYPE` /
|
||||
* `KIMI_MODEL_API_KEY` / `KIMI_MODEL_BASE_URL` environment bindings that
|
||||
* synthesize the reserved `__kimi_env__` provider entry, and the snake_case ↔
|
||||
* camelCase TOML transforms (including the nested `oauth` / `env` /
|
||||
* `custom_headers` normalization). Self-registered at module load via
|
||||
* `registerConfigSection`, so the `config` domain never imports this domain's
|
||||
* types.
|
||||
*
|
||||
* Side-effect module: production gets it from the `src/index.ts`
|
||||
* side-effect block; tests import it on demand. This module is the sole
|
||||
* owner of the section — the legacy `app/provider/configSection` is gone.
|
||||
*
|
||||
* Note: the `app/config` imports below are deliberately RELATIVE paths — see
|
||||
* `providerService.ts` for the rationale.
|
||||
*/
|
||||
|
||||
import { type ConfigStripEnv, envBindings } from '../../app/config/config';
|
||||
import { registerConfigSection } from '../../app/config/configSectionContributions';
|
||||
import {
|
||||
camelToSnake,
|
||||
cloneRecord,
|
||||
isPlainObject,
|
||||
plainObjectToToml,
|
||||
setDefined,
|
||||
snakeToCamel,
|
||||
transformPlainObject,
|
||||
} from '../../app/config/toml';
|
||||
import {
|
||||
ENV_MODEL_PROVIDER_KEY,
|
||||
PROVIDERS_SECTION,
|
||||
ProviderConfigSchema,
|
||||
ProvidersSectionSchema,
|
||||
} from './provider';
|
||||
|
||||
export const providersEnvBindings = envBindings(ProvidersSectionSchema, {
|
||||
[ENV_MODEL_PROVIDER_KEY]: envBindings(ProviderConfigSchema, {
|
||||
apiKey: 'KIMI_MODEL_API_KEY',
|
||||
type: 'KIMI_MODEL_PROVIDER_TYPE',
|
||||
baseUrl: 'KIMI_MODEL_BASE_URL',
|
||||
}),
|
||||
});
|
||||
|
||||
export const stripProvidersEnv: ConfigStripEnv<Record<string, unknown>> = (value) => {
|
||||
if (value === undefined || value === null || typeof value !== 'object') return value;
|
||||
if (!(ENV_MODEL_PROVIDER_KEY in value)) return value;
|
||||
const out = { ...value };
|
||||
delete out[ENV_MODEL_PROVIDER_KEY];
|
||||
return out;
|
||||
};
|
||||
|
||||
export const providersFromToml = (rawSnake: unknown): unknown => {
|
||||
if (!isPlainObject(rawSnake)) return rawSnake;
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [name, entry] of Object.entries(rawSnake)) {
|
||||
out[name] = isPlainObject(entry) ? providerEntryFromToml(entry) : entry;
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
function providerEntryFromToml(data: Record<string, unknown>): Record<string, unknown> {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(data)) {
|
||||
const targetKey = snakeToCamel(key);
|
||||
if (targetKey === 'oauth') {
|
||||
out[targetKey] = isPlainObject(value) ? transformPlainObject(value) : value;
|
||||
} else if (targetKey === 'env' || targetKey === 'customHeaders') {
|
||||
out[targetKey] = isPlainObject(value) ? cloneRecord(value) : value;
|
||||
} else {
|
||||
out[targetKey] = value;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export const providersToToml = (value: unknown, rawSnake: unknown): unknown => {
|
||||
if (!isPlainObject(value)) return value;
|
||||
const rawSub = cloneRecord(rawSnake);
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [name, entry] of Object.entries(value)) {
|
||||
out[name] = isPlainObject(entry) ? providerEntryToToml(entry, rawSub[name]) : entry;
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
function providerEntryToToml(
|
||||
provider: Record<string, unknown>,
|
||||
rawProvider: unknown,
|
||||
): Record<string, unknown> {
|
||||
const out = cloneRecord(rawProvider);
|
||||
for (const [key, value] of Object.entries(provider)) {
|
||||
if (key === 'oauth' && isPlainObject(value)) {
|
||||
out[camelToSnake(key)] = plainObjectToToml(value, undefined);
|
||||
} else if ((key === 'env' || key === 'customHeaders') && value !== undefined) {
|
||||
out[camelToSnake(key)] = cloneRecord(value);
|
||||
} else {
|
||||
setDefined(out, camelToSnake(key), value);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
registerConfigSection(PROVIDERS_SECTION, ProvidersSectionSchema, {
|
||||
defaultValue: {},
|
||||
env: providersEnvBindings,
|
||||
stripEnv: stripProvidersEnv,
|
||||
fromToml: providersFromToml,
|
||||
toToml: providersToToml,
|
||||
});
|
||||
|
|
@ -7,68 +7,54 @@
|
|||
* serves (static list from `[models.*]`, `/v1/models` discovery, or an
|
||||
* OAuth-managed catalog).
|
||||
*
|
||||
* `ProviderTypeSchema` is deliberately free-form text: vendor identity is NOT
|
||||
* enumerated at parse time. Validation happens at resolve time against the
|
||||
* provider-definition registry (`getProviderDefinition`), which is what
|
||||
* `ProviderType` is deliberately free-form text: vendor identity is NOT
|
||||
* enumerated at the type level. Validation happens at resolve time against
|
||||
* the provider-definition registry (`getProviderDefinition`), which is what
|
||||
* allows external packages to register new vendors without touching this
|
||||
* schema.
|
||||
* contract.
|
||||
*
|
||||
* Owns the `ProviderConfig` / `OAuthRef` models and the `providers` config
|
||||
* section; App-scoped. Higher-level services (auth, model catalog, CLI, UI)
|
||||
* mutate providers through this domain instead of writing config directly.
|
||||
* Owns the `ProviderConfig` / `OAuthRef` types and the in-memory provider
|
||||
* registry contract; App-scoped. Kosong has no persistence — it defines
|
||||
* types only: the `providers` section constant, its zod schema
|
||||
* (compile-time pinned to these types), the env bindings, and the TOML
|
||||
* transforms all live in the persistence wrapper
|
||||
* (`app/kosongConfig/configSection`). Higher-level services (auth,
|
||||
* model catalog, CLI, UI) mutate providers through this domain; persisting
|
||||
* those mutations is the upper layer's job.
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation';
|
||||
import type { Event } from '#/_base/event';
|
||||
import type { Event, IWaitUntil } from '#/_base/event';
|
||||
|
||||
/**
|
||||
* Free-form vendor identity (e.g. `'kimi'`). Not an enum, by design — see the
|
||||
* module header.
|
||||
*/
|
||||
export const ProviderTypeSchema = z.string();
|
||||
export type ProviderType = string;
|
||||
|
||||
export type ProviderType = z.infer<typeof ProviderTypeSchema>;
|
||||
export interface OAuthRef {
|
||||
storage: 'file' | 'keyring';
|
||||
key: string;
|
||||
oauthHost?: string;
|
||||
}
|
||||
|
||||
export const OAuthRefSchema = z.object({
|
||||
storage: z.enum(['file', 'keyring']),
|
||||
key: z.string().min(1),
|
||||
oauthHost: z.string().min(1).optional(),
|
||||
});
|
||||
export type ModelSource = 'static' | 'discover' | 'oauth-catalog';
|
||||
|
||||
export type OAuthRef = z.infer<typeof OAuthRefSchema>;
|
||||
export interface ProviderConfig {
|
||||
modelSource?: ModelSource;
|
||||
|
||||
const StringRecordSchema = z.record(z.string(), z.string());
|
||||
baseUrl?: string;
|
||||
customHeaders?: Record<string, string>;
|
||||
defaultModel?: string;
|
||||
|
||||
export const ModelSourceSchema = z.enum(['static', 'discover', 'oauth-catalog']);
|
||||
export type ModelSource = z.infer<typeof ModelSourceSchema>;
|
||||
type?: ProviderType;
|
||||
apiKey?: string;
|
||||
oauth?: OAuthRef;
|
||||
env?: Record<string, string>;
|
||||
source?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export const ProviderConfigSchema = z.object({
|
||||
modelSource: ModelSourceSchema.optional(),
|
||||
|
||||
baseUrl: z.string().optional(),
|
||||
customHeaders: StringRecordSchema.optional(),
|
||||
defaultModel: z.string().optional(),
|
||||
|
||||
type: ProviderTypeSchema.optional(),
|
||||
apiKey: z.string().optional(),
|
||||
oauth: OAuthRefSchema.optional(),
|
||||
env: StringRecordSchema.optional(),
|
||||
source: z.record(z.string(), z.unknown()).optional(),
|
||||
});
|
||||
|
||||
export type ProviderConfig = z.infer<typeof ProviderConfigSchema>;
|
||||
|
||||
export const PROVIDERS_SECTION = 'providers';
|
||||
|
||||
export const DEFAULT_PROVIDER_SECTION = 'defaultProvider';
|
||||
|
||||
export const ENV_MODEL_PROVIDER_KEY = '__kimi_env__';
|
||||
|
||||
export const ProvidersSectionSchema = z.record(z.string(), ProviderConfigSchema);
|
||||
|
||||
export type ProvidersSection = z.infer<typeof ProvidersSectionSchema>;
|
||||
export type ProvidersSection = Record<string, ProviderConfig>;
|
||||
|
||||
export interface ProvidersChangedEvent {
|
||||
readonly added: readonly string[];
|
||||
|
|
@ -76,15 +62,46 @@ export interface ProvidersChangedEvent {
|
|||
readonly changed: readonly string[];
|
||||
}
|
||||
|
||||
export interface DefaultProviderChangedEvent {
|
||||
readonly id: string | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* The in-memory provider registry. Kosong owns the state and the change
|
||||
* events; persistence is the upper layer's concern (a bridge service hydrates
|
||||
* the registry via `loadAll` and subscribes to the change events to persist
|
||||
* them), so this domain never touches config storage itself.
|
||||
*
|
||||
* Mutations (`set` / `delete` / `replaceAll` / `setDefaultProvider`) wait for
|
||||
* hydration (`ready`) so a caller can never race the initial load, and resolve
|
||||
* only after every change listener has finished the work it registered through
|
||||
* `waitUntil` — the persistence bridge participates this way, so an awaited
|
||||
* mutation means the write has also been persisted. Writes that land an equal
|
||||
* value are silent — no event fires — which is what makes the persistence
|
||||
* bridge's two-way sync terminate.
|
||||
*/
|
||||
export interface IProviderService {
|
||||
readonly _serviceBrand: undefined;
|
||||
|
||||
/** Resolves when the registry has been hydrated (the first `loadAll`). */
|
||||
readonly ready: Promise<void>;
|
||||
readonly onDidChangeProviders: Event<ProvidersChangedEvent>;
|
||||
readonly onDidChangeProviders: Event<ProvidersChangedEvent & IWaitUntil>;
|
||||
/** Fires when the default-provider pointer changes value (incl. clearing). */
|
||||
readonly onDidChangeDefaultProvider: Event<DefaultProviderChangedEvent & IWaitUntil>;
|
||||
get(name: string): ProviderConfig | undefined;
|
||||
list(): Readonly<Record<string, ProviderConfig>>;
|
||||
getDefaultProvider(): string | undefined;
|
||||
set(name: string, config: ProviderConfig): Promise<void>;
|
||||
delete(name: string): Promise<void>;
|
||||
/**
|
||||
* Bulk hydration by the persistence owner: replaces the whole registry and
|
||||
* the default-provider pointer. The first call resolves `ready`; later
|
||||
* calls behave like a deep-equal-aware sync (only real diffs fire events).
|
||||
*/
|
||||
loadAll(providers: ProvidersSection, defaultProvider: string | undefined): void;
|
||||
/** Replaces every provider record; the default-provider pointer is kept. */
|
||||
replaceAll(providers: ProvidersSection): Promise<void>;
|
||||
setDefaultProvider(id: string | undefined): Promise<void>;
|
||||
}
|
||||
|
||||
export const IProviderService: ServiceIdentifier<IProviderService> =
|
||||
|
|
|
|||
|
|
@ -1,79 +1,118 @@
|
|||
/**
|
||||
* `kosong/provider` domain (L2) — `IProviderService` implementation.
|
||||
*
|
||||
* Owns the in-memory view of the `providers` config section, persists changes
|
||||
* through `config`, and forwards section changes as `onDidChangeProviders`
|
||||
* (computed with the shared `sectionDiff.diffRecords`). The section schema
|
||||
* self-registers at module load via `configSection.ts`. Bound at App scope.
|
||||
*
|
||||
* Note: the `app/config` imports below are deliberately RELATIVE paths — this
|
||||
* L2 domain may depend on the L2 config domain, and keeping the dependency
|
||||
* off the `#/` alias makes it visible at a glance.
|
||||
* The in-memory provider registry plus the default-provider pointer. Holds no
|
||||
* config dependency: the persistence bridge hydrates it via `loadAll` and
|
||||
* persists the change events it fires. Bound at App scope.
|
||||
*/
|
||||
|
||||
import { InstantiationType } from '#/_base/di/extensions';
|
||||
import { Disposable } from '#/_base/di/lifecycle';
|
||||
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
|
||||
import { Emitter, type Event } from '#/_base/event';
|
||||
import { AsyncEmitter, type Event, type IWaitUntil } from '#/_base/event';
|
||||
|
||||
import { deepEqual, diffRecords, isEmptyDiff } from '../recordDiff';
|
||||
|
||||
import { IConfigService } from '../../app/config/config';
|
||||
import { diffRecords } from '../../app/config/sectionDiff';
|
||||
import {
|
||||
DEFAULT_PROVIDER_SECTION,
|
||||
type DefaultProviderChangedEvent,
|
||||
type ProviderConfig,
|
||||
type ProvidersChangedEvent,
|
||||
type ProvidersSection,
|
||||
IProviderService,
|
||||
PROVIDERS_SECTION,
|
||||
} from './provider';
|
||||
|
||||
/** Registry mutations are not abortable; `fireAsync` still requires a signal. */
|
||||
const NO_ABORT = new AbortController().signal;
|
||||
|
||||
export class ProviderService extends Disposable implements IProviderService {
|
||||
declare readonly _serviceBrand: undefined;
|
||||
readonly ready: Promise<void>;
|
||||
private readonly _onDidChangeProviders = this._register(new Emitter<ProvidersChangedEvent>());
|
||||
readonly onDidChangeProviders: Event<ProvidersChangedEvent> = this._onDidChangeProviders.event;
|
||||
|
||||
constructor(@IConfigService private readonly config: IConfigService) {
|
||||
super();
|
||||
this.ready = config.ready;
|
||||
this._register(
|
||||
config.onDidChangeConfiguration((e) => {
|
||||
if (e.domain === PROVIDERS_SECTION) {
|
||||
this._onDidChangeProviders.fire(
|
||||
diffRecords<ProviderConfig>(
|
||||
e.previousValue as ProvidersSection | undefined,
|
||||
e.value as ProvidersSection | undefined,
|
||||
),
|
||||
);
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
private providers: Readonly<Record<string, ProviderConfig>> = {};
|
||||
private defaultProvider: string | undefined;
|
||||
private hydrated = false;
|
||||
private resolveReady!: () => void;
|
||||
readonly ready: Promise<void> = new Promise<void>((resolve) => {
|
||||
this.resolveReady = resolve;
|
||||
});
|
||||
|
||||
private readonly _onDidChangeProviders = this._register(
|
||||
new AsyncEmitter<ProvidersChangedEvent & IWaitUntil>(),
|
||||
);
|
||||
readonly onDidChangeProviders: Event<ProvidersChangedEvent & IWaitUntil> =
|
||||
this._onDidChangeProviders.event;
|
||||
private readonly _onDidChangeDefaultProvider = this._register(
|
||||
new AsyncEmitter<DefaultProviderChangedEvent & IWaitUntil>(),
|
||||
);
|
||||
readonly onDidChangeDefaultProvider: Event<DefaultProviderChangedEvent & IWaitUntil> =
|
||||
this._onDidChangeDefaultProvider.event;
|
||||
|
||||
get(name: string): ProviderConfig | undefined {
|
||||
return this.config.get<ProvidersSection>(PROVIDERS_SECTION)?.[name];
|
||||
return this.providers[name];
|
||||
}
|
||||
|
||||
list(): Readonly<Record<string, ProviderConfig>> {
|
||||
return this.config.get<ProvidersSection>(PROVIDERS_SECTION) ?? {};
|
||||
return this.providers;
|
||||
}
|
||||
|
||||
getDefaultProvider(): string | undefined {
|
||||
return this.defaultProvider;
|
||||
}
|
||||
|
||||
loadAll(providers: ProvidersSection, defaultProvider: string | undefined): void {
|
||||
// Fire-and-forget on purpose: hydration has no persistence participant
|
||||
// yet, and `fireAsync` still invokes every listener synchronously up to
|
||||
// its own first await, so config-originated syncs keep their timing.
|
||||
void this.applyRecords(providers);
|
||||
void this.applyDefaultProvider(defaultProvider);
|
||||
if (!this.hydrated) {
|
||||
this.hydrated = true;
|
||||
this.resolveReady();
|
||||
}
|
||||
}
|
||||
|
||||
async replaceAll(providers: ProvidersSection): Promise<void> {
|
||||
await this.ready;
|
||||
await this.applyRecords(providers);
|
||||
}
|
||||
|
||||
async set(name: string, config: ProviderConfig): Promise<void> {
|
||||
await this.config.set(PROVIDERS_SECTION, { [name]: config });
|
||||
await this.ready;
|
||||
if (deepEqual(this.providers[name], config)) return;
|
||||
await this.applyRecords({ ...this.providers, [name]: config });
|
||||
}
|
||||
|
||||
async delete(name: string): Promise<void> {
|
||||
const current = this.config.get<ProvidersSection>(PROVIDERS_SECTION) ?? {};
|
||||
if (!(name in current)) return;
|
||||
const { [name]: _removed, ...rest } = current;
|
||||
await this.config.replace(PROVIDERS_SECTION, rest);
|
||||
if (this.config.get<string>(DEFAULT_PROVIDER_SECTION) === name) {
|
||||
// Delete, not merge: `set(domain, undefined)` resolves back to the base
|
||||
// value by design — only `replace(domain, undefined)` removes the key,
|
||||
// otherwise the default-provider id dangles to a deleted provider.
|
||||
await this.config.replace(DEFAULT_PROVIDER_SECTION, undefined);
|
||||
await this.ready;
|
||||
if (!(name in this.providers)) return;
|
||||
const { [name]: _removed, ...rest } = this.providers;
|
||||
await this.applyRecords(rest);
|
||||
// Deleting the provider the default pointer targets must clear the
|
||||
// pointer too, otherwise it dangles to a deleted provider.
|
||||
if (this.defaultProvider === name) {
|
||||
await this.applyDefaultProvider(undefined);
|
||||
}
|
||||
}
|
||||
|
||||
async setDefaultProvider(id: string | undefined): Promise<void> {
|
||||
await this.ready;
|
||||
await this.applyDefaultProvider(id);
|
||||
}
|
||||
|
||||
private async applyRecords(next: Readonly<Record<string, ProviderConfig>>): Promise<void> {
|
||||
const diff = diffRecords(this.providers, next);
|
||||
if (isEmptyDiff(diff)) return;
|
||||
this.providers = { ...next };
|
||||
// Awaiting delivery is what lets a mutation's caller rely on the
|
||||
// persistence bridge's `waitUntil` participation: the returned promise
|
||||
// only settles once the write has reached the config layer.
|
||||
await this._onDidChangeProviders.fireAsync(diff, NO_ABORT);
|
||||
}
|
||||
|
||||
private async applyDefaultProvider(id: string | undefined): Promise<void> {
|
||||
if (this.defaultProvider === id) return;
|
||||
this.defaultProvider = id;
|
||||
await this._onDidChangeDefaultProvider.fireAsync({ id }, NO_ABORT);
|
||||
}
|
||||
}
|
||||
|
||||
registerScopedService(
|
||||
|
|
|
|||
59
packages/agent-core-v2/src/kosong/recordDiff.ts
Normal file
59
packages/agent-core-v2/src/kosong/recordDiff.ts
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
/**
|
||||
* kosong internal — record-level diffing for the provider/model registries.
|
||||
*
|
||||
* `diffRecords` computes the added/removed/changed keys between two snapshots
|
||||
* of a record-shaped registry state, `deepEqual` is the value comparison it
|
||||
* uses. Pure functions; the registries use them to keep change events quiet
|
||||
* when a write lands an equal value (which is also what makes the
|
||||
* persistence bridge's two-way sync terminate).
|
||||
*/
|
||||
|
||||
export interface RecordDiff {
|
||||
readonly added: readonly string[];
|
||||
readonly removed: readonly string[];
|
||||
readonly changed: readonly string[];
|
||||
}
|
||||
|
||||
export function isEmptyDiff(diff: RecordDiff): boolean {
|
||||
return diff.added.length === 0 && diff.removed.length === 0 && diff.changed.length === 0;
|
||||
}
|
||||
|
||||
export function diffRecords<T>(
|
||||
previous: Readonly<Record<string, T>> | undefined,
|
||||
current: Readonly<Record<string, T>> | undefined,
|
||||
): RecordDiff {
|
||||
const prev = previous ?? {};
|
||||
const curr = current ?? {};
|
||||
const added: string[] = [];
|
||||
const removed: string[] = [];
|
||||
const changed: string[] = [];
|
||||
for (const key of Object.keys(curr)) {
|
||||
if (!(key in prev)) {
|
||||
added.push(key);
|
||||
} else if (!deepEqual(prev[key], curr[key])) {
|
||||
changed.push(key);
|
||||
}
|
||||
}
|
||||
for (const key of Object.keys(prev)) {
|
||||
if (!(key in curr)) {
|
||||
removed.push(key);
|
||||
}
|
||||
}
|
||||
return { added, removed, changed };
|
||||
}
|
||||
|
||||
export function deepEqual(a: unknown, b: unknown): boolean {
|
||||
if (Object.is(a, b)) return true;
|
||||
if (typeof a !== 'object' || typeof b !== 'object' || a === null || b === null) return false;
|
||||
if (Array.isArray(a) !== Array.isArray(b)) return false;
|
||||
const aKeys = Object.keys(a);
|
||||
const bKeys = Object.keys(b);
|
||||
if (aKeys.length !== bKeys.length) return false;
|
||||
for (const key of aKeys) {
|
||||
if (!Object.prototype.hasOwnProperty.call(b, key)) return false;
|
||||
if (!deepEqual((a as Record<string, unknown>)[key], (b as Record<string, unknown>)[key])) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
|
@ -39,7 +39,7 @@ import type { TestAgentContext, TestAgentOptions, TestAgentServiceOverride } fro
|
|||
import { appServices, createCommandRunner, execEnvServices, hostEnvironmentServices, sessionServices, testAgent } from '../../harness';
|
||||
import {
|
||||
IAgentFullCompactionService,
|
||||
IOAuthService,
|
||||
IModelOAuthTokens,
|
||||
IAgentProfileService,
|
||||
IAgentToolRegistryService,
|
||||
ISessionTodoService,
|
||||
|
|
@ -2935,9 +2935,15 @@ function oauthTestAgentOptions(
|
|||
},
|
||||
},
|
||||
services: appServices((reg) => {
|
||||
reg.definePartialInstance(IOAuthService, {
|
||||
resolveTokenProvider: () => ({ getAccessToken }),
|
||||
});
|
||||
// The catalog's OAuth port is `IModelOAuthTokens` (the app/kosongConfig
|
||||
// adapter delegates it to IOAuthService in production); stub the port
|
||||
// directly, mirroring the adapter's force-flag normalization.
|
||||
reg.defineInstance(IModelOAuthTokens, {
|
||||
_serviceBrand: undefined,
|
||||
hasCachedAccessToken: () => Promise.resolve(true),
|
||||
getAccessToken: (_provider, _oauthRef, options) =>
|
||||
getAccessToken(options?.force === true ? { force: true } : undefined),
|
||||
} satisfies IModelOAuthTokens);
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@ import type { Message } from '#/kosong/contract/message';
|
|||
import type { ThinkingEffort } from '#/kosong/contract/provider';
|
||||
import type { ModelCapability } from '#/kosong/contract/capability';
|
||||
import { IModelCatalog, type Model } from '#/kosong/model/catalog';
|
||||
import { IModelService } from '#/kosong/model/model';
|
||||
import {
|
||||
type ModelRequestEvent,
|
||||
type ModelRequestInput,
|
||||
|
|
@ -223,6 +224,9 @@ function createService(
|
|||
getRequester: () => requester,
|
||||
findByName: () => [],
|
||||
});
|
||||
ix.stub(IModelService, {
|
||||
get: () => undefined,
|
||||
});
|
||||
const records: WireRecord[] = [];
|
||||
registerTestAgentWire(ix, 'wire/llm-requester', {
|
||||
log: recordingWireLog(records),
|
||||
|
|
|
|||
|
|
@ -33,7 +33,8 @@ import { ConfigRegistry } from '#/app/config/configService';
|
|||
import { type DomainEvent, IEventService } from '#/app/event/event';
|
||||
import { ILogService } from '#/_base/log/log';
|
||||
import { IHostRequestHeaders } from '#/kosong/model/hostRequestHeaders';
|
||||
import { MODELS_SECTION, type ModelRecord } from '#/kosong/model/model';
|
||||
import { IModelService, type ModelRecord } from '#/kosong/model/model';
|
||||
import { MODELS_SECTION } from '#/app/kosongConfig/configSection';
|
||||
import { IProviderService, type ProviderConfig, type ProvidersChangedEvent } from '#/kosong/provider/provider';
|
||||
|
||||
// Side-effect registration: the OAuth-catalog verdict
|
||||
|
|
@ -162,7 +163,7 @@ describe('OAuthService', () => {
|
|||
get: ((name: string) => providers[name]) as IProviderService['get'],
|
||||
list: (() => providers) as IProviderService['list'],
|
||||
set: providerSet as unknown as IProviderService['set'],
|
||||
onDidChangeProviders: providerChangedEmitter.event,
|
||||
onDidChangeProviders: providerChangedEmitter.event as IProviderService['onDidChangeProviders'],
|
||||
});
|
||||
reg.definePartialInstance(IConfigService, {
|
||||
get: ((domain: string) => configBacking()[domain]) as IConfigService['get'],
|
||||
|
|
@ -1141,6 +1142,11 @@ describe('AuthSummaryService', () => {
|
|||
get: ((name: string) => providers[name]) as IProviderService['get'],
|
||||
list: (() => providers) as IProviderService['list'],
|
||||
});
|
||||
reg.definePartialInstance(IModelService, {
|
||||
get: ((id: string) => models[id]) as IModelService['get'],
|
||||
list: (() => models) as IModelService['list'],
|
||||
getDefaultModel: (() => defaultModel) as IModelService['getDefaultModel'],
|
||||
});
|
||||
reg.definePartialInstance(IConfigService, {
|
||||
get: ((domain: string) => {
|
||||
if (domain === MODELS_SECTION) return models;
|
||||
|
|
@ -1278,6 +1284,10 @@ describe('AuthLegacyService', () => {
|
|||
reg.definePartialInstance(IProviderService, {
|
||||
list: (() => providers) as IProviderService['list'],
|
||||
});
|
||||
reg.definePartialInstance(IModelService, {
|
||||
ready: Promise.resolve(),
|
||||
getDefaultModel: (() => defaultModel) as IModelService['getDefaultModel'],
|
||||
});
|
||||
reg.definePartialInstance(IConfigService, {
|
||||
ready: Promise.resolve(),
|
||||
get: ((domain: string) =>
|
||||
|
|
|
|||
|
|
@ -1,17 +1,26 @@
|
|||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { InstantiationType } from '#/_base/di/extensions';
|
||||
import { LifecycleScope, _clearScopedRegistryForTests, registerScopedService } from '#/_base/di/scope';
|
||||
import type { ServiceIdentifier } from '#/_base/di/instantiation';
|
||||
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
|
||||
import { createScopedTestHost } from '#/_base/di/test';
|
||||
import { IBootstrapService, bootstrapSeed, resolveBootstrapOptions } from '#/app/bootstrap/bootstrap';
|
||||
import { bootstrap } from '#/app/bootstrap/bootstrap';
|
||||
import {
|
||||
IBootstrapService,
|
||||
bootstrap,
|
||||
bootstrapSeed,
|
||||
resolveBootstrapOptions,
|
||||
} from '#/app/bootstrap/bootstrap';
|
||||
import { BootstrapService } from '#/app/bootstrap/bootstrapService';
|
||||
import { IKosongConfigService } from '#/app/kosongConfig/kosongConfig';
|
||||
import { FileStorageService } from '#/persistence/backends/node-fs/fileStorageService';
|
||||
import { IFileSystemStorageService } from '#/persistence/interface/storage';
|
||||
|
||||
describe('BootstrapService (scoped)', () => {
|
||||
beforeEach(() => {
|
||||
_clearScopedRegistryForTests();
|
||||
// No `_clearScopedRegistryForTests()` here: the registry is process-wide,
|
||||
// and wiping it would break other suites sharing this worker.
|
||||
// Re-registering is enough — later registrations win in the scope
|
||||
// collection.
|
||||
registerScopedService(
|
||||
LifecycleScope.App,
|
||||
IBootstrapService,
|
||||
|
|
@ -49,7 +58,15 @@ describe('resolveBootstrapOptions', () => {
|
|||
|
||||
describe('bootstrap() storage seeding', () => {
|
||||
it('seeds IFileSystemStorageService as a FileStorageService instance', () => {
|
||||
const { app } = bootstrap({ homeDir: '/tmp/kimi-home' });
|
||||
// `bootstrap()` eagerly instantiates the kosong persistence bridge; stub
|
||||
// it out so this test stays focused on the storage seed instead of
|
||||
// pulling the whole config/kosong graph into the module imports.
|
||||
const { app } = bootstrap({ homeDir: '/tmp/kimi-home' }, [
|
||||
[
|
||||
IKosongConfigService as ServiceIdentifier<unknown>,
|
||||
{ _serviceBrand: undefined, ready: Promise.resolve() },
|
||||
],
|
||||
]);
|
||||
try {
|
||||
const storage = app.accessor.get(IFileSystemStorageService);
|
||||
expect(storage).toBeInstanceOf(FileStorageService);
|
||||
|
|
|
|||
|
|
@ -43,10 +43,8 @@ import {
|
|||
LOOP_MAX_STEPS_PER_TURN_ENV,
|
||||
type LoopControl,
|
||||
} from '#/agent/loop/configSection';
|
||||
import {
|
||||
THINKING_SECTION,
|
||||
type ThinkingConfig,
|
||||
} from '#/kosong/model/thinking';
|
||||
import { THINKING_SECTION } from '#/app/kosongConfig/configSection';
|
||||
import { type ThinkingConfig } from '#/kosong/model/thinking';
|
||||
import {
|
||||
KEEP_ALIVE_ON_EXIT_ENV,
|
||||
MAX_RUNNING_TASKS_ENV,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
/**
|
||||
* `kosong/model` discovery tests — `IProviderDiscoveryService`:
|
||||
* `app/kosongConfig` discovery tests — `IProviderDiscoveryService`:
|
||||
*
|
||||
* - `refreshProviderModels` short-circuits `modelSource: 'static'`: a scoped
|
||||
* refresh answers `unchanged` without any I/O, and an unscoped refresh
|
||||
|
|
@ -8,10 +8,12 @@
|
|||
* at them all survive;
|
||||
* - concurrent refreshes serialize (never overlap);
|
||||
* - custom-registry fetches carry the host `User-Agent`;
|
||||
* - whole-section writes merge discovered aliases into user-owned provider
|
||||
* records, restore a surviving default selection, and CLEAR a default
|
||||
* (plus its thinking) whose alias the upstream dropped — through
|
||||
* `replace`, never a dangling `set`;
|
||||
* - provider/model patches land in kosong's in-memory registries via
|
||||
* `replaceAll` (the persistence bridge writes them back to config),
|
||||
* merging discovered aliases into user-owned provider records, while
|
||||
* `defaultModel` / `thinking` still go through `config.replace` directly —
|
||||
* restoring a surviving default selection and CLEARing a default (plus its
|
||||
* thinking) whose alias the upstream dropped, never a dangling `set`;
|
||||
* - the `[modelCatalog]` config section self-registers and validates.
|
||||
*/
|
||||
|
||||
|
|
@ -24,19 +26,27 @@ import { IOAuthService } from '#/app/auth/auth';
|
|||
import { IConfigService } from '#/app/config/config';
|
||||
import { ConfigRegistry } from '#/app/config/configService';
|
||||
import { IEventService } from '#/app/event/event';
|
||||
import { IProviderDiscoveryService } from '#/app/kosongConfig/discovery';
|
||||
import '#/app/kosongConfig/discoveryService';
|
||||
import { MODEL_CATALOG_SECTION } from '#/app/kosongConfig/configSection';
|
||||
import '#/kosong/model/errors';
|
||||
import { HostRequestHeaders, IHostRequestHeaders } from '#/kosong/model/hostRequestHeaders';
|
||||
import type { ModelRecord } from '#/kosong/model/model';
|
||||
import {
|
||||
IModelService,
|
||||
type ModelRecord,
|
||||
type ModelsSection,
|
||||
} from '#/kosong/model/model';
|
||||
import '#/kosong/model/modelService';
|
||||
import { IProviderDiscoveryService } from '#/kosong/model/discovery';
|
||||
import '#/kosong/model/discoveryService';
|
||||
import { MODEL_CATALOG_SECTION } from '#/kosong/model/discoveryConfigSection';
|
||||
import type { ProviderConfig } from '#/kosong/provider/provider';
|
||||
import {
|
||||
IProviderService,
|
||||
type ProviderConfig,
|
||||
type ProvidersSection,
|
||||
} from '#/kosong/provider/provider';
|
||||
import '#/kosong/provider/providerService';
|
||||
import '#/kosong/provider/providers/kimi/kimi.contrib';
|
||||
import '#/kosong/provider/providers/standard.contrib';
|
||||
|
||||
import { StubConfigService, stubOAuthService, stubTokenProvider } from '../stubs';
|
||||
import { StubConfigService, stubOAuthService, stubTokenProvider } from '../../kosong/stubs';
|
||||
|
||||
function stubEvents(): IEventService & { published: Array<{ type: string; payload: unknown }> } {
|
||||
const published: Array<{ type: string; payload: unknown }> = [];
|
||||
|
|
@ -59,6 +69,8 @@ function createHost(
|
|||
config: StubConfigService;
|
||||
events: ReturnType<typeof stubEvents>;
|
||||
discovery: IProviderDiscoveryService;
|
||||
providers: IProviderService;
|
||||
models: IModelService;
|
||||
} {
|
||||
const config = new StubConfigService(sections);
|
||||
const events = stubEvents();
|
||||
|
|
@ -68,7 +80,26 @@ function createHost(
|
|||
[IEventService, events],
|
||||
[IHostRequestHeaders, new HostRequestHeaders({ 'User-Agent': 'kimi-test/1.0' })],
|
||||
]);
|
||||
return { host, config, events, discovery: host.app.accessor.get(IProviderDiscoveryService) };
|
||||
// Seed the in-memory registries from the fixture sections (the persistence
|
||||
// bridge that normally does this is not instantiated here).
|
||||
const providers = host.app.accessor.get(IProviderService);
|
||||
providers.loadAll(
|
||||
(sections['providers'] ?? {}) as ProvidersSection,
|
||||
sections['defaultProvider'] as string | undefined,
|
||||
);
|
||||
const models = host.app.accessor.get(IModelService);
|
||||
models.loadAll(
|
||||
(sections['models'] ?? {}) as ModelsSection,
|
||||
sections['defaultModel'] as string | undefined,
|
||||
);
|
||||
return {
|
||||
host,
|
||||
config,
|
||||
events,
|
||||
discovery: host.app.accessor.get(IProviderDiscoveryService),
|
||||
providers,
|
||||
models,
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
|
|
@ -133,7 +164,7 @@ describe('refreshProviderModels modelSource short-circuit', () => {
|
|||
);
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const { host, config, discovery, events } = createHost({
|
||||
const { host, config, discovery, events, providers, models } = createHost({
|
||||
providers: {
|
||||
...staticProviders,
|
||||
acme: {
|
||||
|
|
@ -159,13 +190,15 @@ describe('refreshProviderModels modelSource short-circuit', () => {
|
|||
]);
|
||||
|
||||
// Static provider, its model, the default selection, and its thinking
|
||||
// all survived the orchestrator's whole-section writes.
|
||||
const providers = config.get<Record<string, ProviderConfig>>('providers');
|
||||
expect(Object.keys(providers).toSorted()).toEqual(['acme', 'static-p']);
|
||||
expect(providers['static-p']).toEqual({ type: 'openai', modelSource: 'static', apiKey: 'sk-static' });
|
||||
const models = config.get<Record<string, ModelRecord>>('models');
|
||||
expect(models['s1']).toEqual({ provider: 'static-p', model: 'static-model', maxContextSize: 1000 });
|
||||
expect(models['acme/m1']).toBeDefined();
|
||||
// all survived the orchestrator's whole-section writes. Providers and
|
||||
// models land in the in-memory registries (the persistence bridge owns
|
||||
// the config write-back); defaultModel/thinking go through config.
|
||||
const providerRecords = providers.list();
|
||||
expect(Object.keys(providerRecords).toSorted()).toEqual(['acme', 'static-p']);
|
||||
expect(providerRecords['static-p']).toEqual({ type: 'openai', modelSource: 'static', apiKey: 'sk-static' });
|
||||
const modelRecords = models.list();
|
||||
expect(modelRecords['s1']).toEqual({ provider: 'static-p', model: 'static-model', maxContextSize: 1000 });
|
||||
expect(modelRecords['acme/m1']).toBeDefined();
|
||||
expect(config.get<string>('defaultModel')).toBe('s1');
|
||||
expect(config.get('thinking')).toEqual({ enabled: true });
|
||||
} finally {
|
||||
|
|
@ -304,7 +337,7 @@ describe('refreshProviderModels write behavior', () => {
|
|||
);
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const { host, config, discovery, events } = createHost({
|
||||
const { host, config, discovery, events, providers, models } = createHost({
|
||||
providers: {
|
||||
'my-kimi': { type: 'kimi', baseUrl, apiKey: 'sk-distributed-key' },
|
||||
},
|
||||
|
|
@ -335,14 +368,14 @@ describe('refreshProviderModels write behavior', () => {
|
|||
}),
|
||||
);
|
||||
// The user-owned provider record survives; only model aliases are merged.
|
||||
expect(config.get<Record<string, ProviderConfig>>('providers')['my-kimi']).toEqual({
|
||||
expect(providers.list()['my-kimi']).toEqual({
|
||||
type: 'kimi',
|
||||
baseUrl,
|
||||
apiKey: 'sk-distributed-key',
|
||||
});
|
||||
const models = config.get<Record<string, ModelRecord>>('models');
|
||||
expect(models['my-kimi/kimi-k2']?.displayName).toBe('Fresh K2');
|
||||
expect(models['my-kimi/kimi-k2.5']).toBeDefined();
|
||||
const modelRecords = models.list();
|
||||
expect(modelRecords['my-kimi/kimi-k2']?.displayName).toBe('Fresh K2');
|
||||
expect(modelRecords['my-kimi/kimi-k2.5']).toBeDefined();
|
||||
// The surviving default selection is written back, not cleared.
|
||||
expect(config.get<string>('defaultModel')).toBe('my-kimi/kimi-k2');
|
||||
} finally {
|
||||
|
|
@ -364,7 +397,7 @@ describe('refreshProviderModels write behavior', () => {
|
|||
);
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const { host, config, discovery } = createHost({
|
||||
const { host, config, discovery, models } = createHost({
|
||||
providers: {
|
||||
'my-kimi': { type: 'kimi', baseUrl, apiKey: 'sk-distributed-key' },
|
||||
},
|
||||
|
|
@ -392,9 +425,9 @@ describe('refreshProviderModels write behavior', () => {
|
|||
// back to the stale base value.
|
||||
expect(config.get('defaultModel')).toBeUndefined();
|
||||
expect(config.get('thinking')).toBeUndefined();
|
||||
const models = config.get<Record<string, ModelRecord>>('models');
|
||||
expect(models['my-kimi/kimi-k3']).toBeDefined();
|
||||
expect(models['my-kimi/kimi-k2']).toBeUndefined();
|
||||
const modelRecords = models.list();
|
||||
expect(modelRecords['my-kimi/kimi-k3']).toBeDefined();
|
||||
expect(modelRecords['my-kimi/kimi-k2']).toBeUndefined();
|
||||
} finally {
|
||||
host.dispose();
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
/**
|
||||
* `kosong/model` envOverlay tests — the `KIMI_MODEL_*` effective overlay:
|
||||
* `app/kosongConfig` envOverlay tests — the `KIMI_MODEL_*` effective overlay:
|
||||
*
|
||||
* - with `KIMI_MODEL_NAME` set it synthesizes the reserved env model +
|
||||
* provider entries and selects the model; without it only the
|
||||
|
|
@ -13,10 +13,10 @@
|
|||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { ENV_MODEL_PROVIDER_KEY } from '#/kosong/provider/provider';
|
||||
import { ENV_MODEL_PROVIDER_KEY } from '#/app/kosongConfig/configSection';
|
||||
import '#/kosong/provider/providers/kimi/kimi.contrib';
|
||||
import '#/kosong/provider/providers/standard.contrib';
|
||||
import { ENV_MODEL_ALIAS_KEY, kimiModelEnvOverlay } from '#/kosong/model/envOverlay';
|
||||
import { ENV_MODEL_ALIAS_KEY, kimiModelEnvOverlay } from '#/app/kosongConfig/envOverlay';
|
||||
|
||||
type Env = Record<string, string>;
|
||||
|
||||
|
|
@ -0,0 +1,409 @@
|
|||
/**
|
||||
* `kosongConfig` bridge tests — `KosongConfigService`, the two-way sync
|
||||
* between `IConfigService` (persistence) and kosong's in-memory
|
||||
* provider/model registries:
|
||||
*
|
||||
* - startup hydration: after `config.ready` the registries are loaded from
|
||||
* the effective config view (records + default pointers) and become
|
||||
* `ready` themselves;
|
||||
* - kosong → config: registry mutations (`set` / `setDefaultModel` / ...)
|
||||
* persist through `config.replace`, serialized in event order; an awaited
|
||||
* mutation only resolves after the persist has landed, and a failed
|
||||
* persist is retried with backoff before being logged;
|
||||
* - config → kosong: section writes (`config.set` / `config.replace`) land
|
||||
* in the registries;
|
||||
* - loop termination: equal writes are silent on the registry side and the
|
||||
* persist handlers skip writes when config already matches, so neither
|
||||
* direction echoes back into the other;
|
||||
* - deleting the default provider clears the pointer and persists the
|
||||
* cleared pointer.
|
||||
*
|
||||
* The bridge is instantiated directly with the real registries, the shared
|
||||
* `StubConfigService`, and a stub log — no DI involved.
|
||||
*/
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { ILogService, type LogPayload } from '#/_base/log/log';
|
||||
import {
|
||||
DEFAULT_MODEL_SECTION,
|
||||
DEFAULT_PROVIDER_SECTION,
|
||||
MODELS_SECTION,
|
||||
PROVIDERS_SECTION,
|
||||
} from '#/app/kosongConfig/configSection';
|
||||
import { type ModelRecord } from '#/kosong/model/model';
|
||||
import { ModelService } from '#/kosong/model/modelService';
|
||||
import { type ProviderConfig } from '#/kosong/provider/provider';
|
||||
import { ProviderService } from '#/kosong/provider/providerService';
|
||||
|
||||
import { StubConfigService } from '../../kosong/stubs';
|
||||
import { KosongConfigService } from '#/app/kosongConfig/kosongConfigService';
|
||||
|
||||
function stubLogService(): ILogService & { warnings: Array<{ message: string; payload?: LogPayload }> } {
|
||||
const warnings: Array<{ message: string; payload?: LogPayload }> = [];
|
||||
return {
|
||||
warnings,
|
||||
_serviceBrand: undefined,
|
||||
level: 'debug',
|
||||
setLevel: () => {},
|
||||
flush: async () => {},
|
||||
error: () => {},
|
||||
warn: (message: string, payload?: LogPayload) => {
|
||||
warnings.push({ message, payload });
|
||||
},
|
||||
info: () => {},
|
||||
debug: () => {},
|
||||
child: () => {
|
||||
throw new Error('child loggers are not used by KosongConfigService');
|
||||
},
|
||||
} satisfies ILogService & { warnings: Array<{ message: string; payload?: LogPayload }> };
|
||||
}
|
||||
|
||||
interface BridgeFixture {
|
||||
readonly config: StubConfigService;
|
||||
readonly providers: ProviderService;
|
||||
readonly models: ModelService;
|
||||
readonly log: ReturnType<typeof stubLogService>;
|
||||
readonly bridge: KosongConfigService;
|
||||
}
|
||||
|
||||
async function createBridge(sections: Record<string, unknown> = {}): Promise<BridgeFixture> {
|
||||
const config = new StubConfigService(sections);
|
||||
const providers = new ProviderService();
|
||||
const models = new ModelService();
|
||||
const log = stubLogService();
|
||||
const bridge = new KosongConfigService(config, providers, models, log);
|
||||
await bridge.ready;
|
||||
return { config, providers, models, log, bridge };
|
||||
}
|
||||
|
||||
/** Let the bridge's serialized persist chain (and event handlers) run out. */
|
||||
async function flush(): Promise<void> {
|
||||
for (let i = 0; i < 10; i += 1) {
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
}
|
||||
}
|
||||
|
||||
const KIMI_PROVIDER: ProviderConfig = { type: 'kimi', apiKey: 'sk-test' };
|
||||
const K1_MODEL: ModelRecord = { provider: 'kimi', model: 'kimi-k2', maxContextSize: 1000 };
|
||||
|
||||
const seededSections: Record<string, unknown> = {
|
||||
providers: { kimi: KIMI_PROVIDER },
|
||||
models: { k1: K1_MODEL },
|
||||
defaultProvider: 'kimi',
|
||||
defaultModel: 'k1',
|
||||
};
|
||||
|
||||
describe('KosongConfigService startup hydration', () => {
|
||||
it('loads providers, models, and the default pointers from config and readies the registries', async () => {
|
||||
const { providers, models } = await createBridge(seededSections);
|
||||
|
||||
expect(providers.list()).toEqual({ kimi: KIMI_PROVIDER });
|
||||
expect(providers.getDefaultProvider()).toBe('kimi');
|
||||
expect(models.list()).toEqual({ k1: K1_MODEL });
|
||||
expect(models.getDefaultModel()).toBe('k1');
|
||||
// Both registries are hydrated — `ready` has resolved.
|
||||
await expect(providers.ready).resolves.toBeUndefined();
|
||||
await expect(models.ready).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('hydrates empty registries from an empty config', async () => {
|
||||
const { providers, models } = await createBridge();
|
||||
|
||||
expect(providers.list()).toEqual({});
|
||||
expect(providers.getDefaultProvider()).toBeUndefined();
|
||||
expect(models.list()).toEqual({});
|
||||
expect(models.getDefaultModel()).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('KosongConfigService kosong → config persistence', () => {
|
||||
it('persists provider set/delete through config.replace with the whole section', async () => {
|
||||
const { config, providers, bridge } = await createBridge(seededSections);
|
||||
try {
|
||||
const replaceSpy = vi.spyOn(config, 'replace');
|
||||
|
||||
await providers.set('openai', { type: 'openai', apiKey: 'sk-o' });
|
||||
await flush();
|
||||
expect(replaceSpy).toHaveBeenCalledWith(PROVIDERS_SECTION, {
|
||||
kimi: KIMI_PROVIDER,
|
||||
openai: { type: 'openai', apiKey: 'sk-o' },
|
||||
});
|
||||
expect(config.get<Record<string, ProviderConfig>>(PROVIDERS_SECTION)).toEqual({
|
||||
kimi: KIMI_PROVIDER,
|
||||
openai: { type: 'openai', apiKey: 'sk-o' },
|
||||
});
|
||||
|
||||
await providers.delete('openai');
|
||||
await flush();
|
||||
expect(config.get<Record<string, ProviderConfig>>(PROVIDERS_SECTION)).toEqual({
|
||||
kimi: KIMI_PROVIDER,
|
||||
});
|
||||
} finally {
|
||||
bridge.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it('persists model records and the default-model pointer', async () => {
|
||||
const { config, models, bridge } = await createBridge(seededSections);
|
||||
try {
|
||||
await models.set('k2', { provider: 'kimi', model: 'kimi-k2.5', maxContextSize: 2000 });
|
||||
await models.setDefaultModel('k2');
|
||||
await flush();
|
||||
|
||||
expect(config.get<Record<string, ModelRecord>>(MODELS_SECTION)).toEqual({
|
||||
k1: K1_MODEL,
|
||||
k2: { provider: 'kimi', model: 'kimi-k2.5', maxContextSize: 2000 },
|
||||
});
|
||||
expect(config.get<string>(DEFAULT_MODEL_SECTION)).toBe('k2');
|
||||
} finally {
|
||||
bridge.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it('persists the default-provider pointer', async () => {
|
||||
const { config, providers, bridge } = await createBridge(seededSections);
|
||||
try {
|
||||
await providers.set('openai', { type: 'openai' });
|
||||
await providers.setDefaultProvider('openai');
|
||||
await flush();
|
||||
|
||||
expect(config.get<string>(DEFAULT_PROVIDER_SECTION)).toBe('openai');
|
||||
} finally {
|
||||
bridge.dispose();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('KosongConfigService awaited-mutation semantics', () => {
|
||||
it('an awaited registry mutation resolves only after the write has landed in config', async () => {
|
||||
const { config, providers, models, bridge } = await createBridge(seededSections);
|
||||
try {
|
||||
// No flush(): the mutation's own await already covers persistence.
|
||||
await providers.set('openai', { type: 'openai', apiKey: 'sk-o' });
|
||||
expect(config.get<Record<string, ProviderConfig>>(PROVIDERS_SECTION)).toEqual({
|
||||
kimi: KIMI_PROVIDER,
|
||||
openai: { type: 'openai', apiKey: 'sk-o' },
|
||||
});
|
||||
|
||||
await models.setDefaultModel('k1');
|
||||
expect(config.get<string>(DEFAULT_MODEL_SECTION)).toBe('k1');
|
||||
} finally {
|
||||
bridge.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it('retries a failed persist instead of surfacing it to the caller', async () => {
|
||||
const { config, providers, log, bridge } = await createBridge(seededSections);
|
||||
try {
|
||||
let failuresLeft = 1;
|
||||
const original = config.replace.bind(config);
|
||||
vi.spyOn(config, 'replace').mockImplementation(async (domain: string, value: unknown) => {
|
||||
if (domain === PROVIDERS_SECTION && failuresLeft > 0) {
|
||||
failuresLeft -= 1;
|
||||
throw new Error('disk busy');
|
||||
}
|
||||
return original(domain, value);
|
||||
});
|
||||
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const pending = providers.set('openai', { type: 'openai' });
|
||||
// The first backoff is ~500ms; advancing past it lets the retry run.
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
await pending;
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
|
||||
expect(config.get<Record<string, ProviderConfig>>(PROVIDERS_SECTION)).toEqual({
|
||||
kimi: KIMI_PROVIDER,
|
||||
openai: { type: 'openai' },
|
||||
});
|
||||
expect(log.warnings).toHaveLength(0);
|
||||
} finally {
|
||||
bridge.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it('logs and resolves after the retry budget is spent, and the chain stays alive', async () => {
|
||||
const { config, providers, log, bridge } = await createBridge(seededSections);
|
||||
try {
|
||||
const replaceSpy = vi.spyOn(config, 'replace').mockRejectedValue(new Error('disk gone'));
|
||||
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const pending = providers.set('openai', { type: 'openai' });
|
||||
// Two backoffs (~500ms + ~1000ms, plus jitter) before the budget is spent.
|
||||
await vi.advanceTimersByTimeAsync(2500);
|
||||
// The caller is never rejected: the in-memory change stands.
|
||||
await pending;
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
|
||||
expect(providers.get('openai')).toEqual({ type: 'openai' });
|
||||
expect(config.get<Record<string, ProviderConfig>>(PROVIDERS_SECTION)).toEqual({
|
||||
kimi: KIMI_PROVIDER,
|
||||
});
|
||||
expect(log.warnings).toHaveLength(1);
|
||||
expect(log.warnings[0]?.message).toBe('kosong config persist failed');
|
||||
|
||||
// A poisoned task must not stall the persists queued behind it.
|
||||
replaceSpy.mockRestore();
|
||||
await providers.set('mistral', { type: 'mistral' });
|
||||
expect(config.get<Record<string, ProviderConfig>>(PROVIDERS_SECTION)).toEqual({
|
||||
kimi: KIMI_PROVIDER,
|
||||
openai: { type: 'openai' },
|
||||
mistral: { type: 'mistral' },
|
||||
});
|
||||
} finally {
|
||||
bridge.dispose();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('KosongConfigService config → kosong sync', () => {
|
||||
it('pushes config section writes into the registries', async () => {
|
||||
const { config, providers, models, bridge } = await createBridge(seededSections);
|
||||
try {
|
||||
await config.set(PROVIDERS_SECTION, { openai: { type: 'openai', apiKey: 'sk-o' } });
|
||||
expect(providers.get('openai')).toEqual({ type: 'openai', apiKey: 'sk-o' });
|
||||
expect(providers.get('kimi')).toEqual(KIMI_PROVIDER);
|
||||
|
||||
await config.replace(MODELS_SECTION, { k2: { provider: 'openai', model: 'gpt-5' } });
|
||||
expect(models.list()).toEqual({ k2: { provider: 'openai', model: 'gpt-5' } });
|
||||
|
||||
await config.replace(DEFAULT_MODEL_SECTION, 'k2');
|
||||
await flush();
|
||||
expect(models.getDefaultModel()).toBe('k2');
|
||||
|
||||
await config.replace(DEFAULT_PROVIDER_SECTION, 'openai');
|
||||
await flush();
|
||||
expect(providers.getDefaultProvider()).toBe('openai');
|
||||
} finally {
|
||||
bridge.dispose();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('KosongConfigService loop termination', () => {
|
||||
it('a kosong-originated persist does not echo back as a kosong change', async () => {
|
||||
const { config, providers, bridge } = await createBridge(seededSections);
|
||||
try {
|
||||
const replaceSpy = vi.spyOn(config, 'replace');
|
||||
const events: string[] = [];
|
||||
providers.onDidChangeProviders(() => events.push('providers'));
|
||||
providers.onDidChangeDefaultProvider(() => events.push('defaultProvider'));
|
||||
|
||||
await providers.set('openai', { type: 'openai' });
|
||||
await providers.setDefaultProvider('openai');
|
||||
await flush();
|
||||
|
||||
// Exactly one event per mutation; the config write the persist caused
|
||||
// synced back equal values, which are silent.
|
||||
expect(events).toEqual(['providers', 'defaultProvider']);
|
||||
// And the persist did not re-persist after the echo: exactly one
|
||||
// providers-section replace plus one pointer replace.
|
||||
expect(
|
||||
replaceSpy.mock.calls.filter(([domain]) => domain === PROVIDERS_SECTION),
|
||||
).toHaveLength(1);
|
||||
expect(
|
||||
replaceSpy.mock.calls.filter(([domain]) => domain === DEFAULT_PROVIDER_SECTION),
|
||||
).toHaveLength(1);
|
||||
} finally {
|
||||
bridge.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it('a config-originated sync does not echo back as a config persist', async () => {
|
||||
const { config, providers, models, bridge } = await createBridge(seededSections);
|
||||
try {
|
||||
const replaceSpy = vi.spyOn(config, 'replace');
|
||||
replaceSpy.mockClear();
|
||||
|
||||
await config.set(PROVIDERS_SECTION, { openai: { type: 'openai' } });
|
||||
await config.set(MODELS_SECTION, { k2: { provider: 'openai', model: 'gpt-5' } });
|
||||
await flush();
|
||||
|
||||
expect(providers.get('openai')).toEqual({ type: 'openai' });
|
||||
expect(models.get('k2')).toEqual({ provider: 'openai', model: 'gpt-5' });
|
||||
// The registry diffs fired, but the persist handlers saw config already
|
||||
// matching and skipped the write-back.
|
||||
expect(replaceSpy).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
bridge.dispose();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('KosongConfigService default-provider deletion', () => {
|
||||
it('clears the pointer when the default provider is deleted and persists the cleared pointer', async () => {
|
||||
const { config, providers, bridge } = await createBridge({
|
||||
...seededSections,
|
||||
providers: { kimi: KIMI_PROVIDER, openai: { type: 'openai' } },
|
||||
});
|
||||
try {
|
||||
const replaceSpy = vi.spyOn(config, 'replace');
|
||||
|
||||
await providers.delete('kimi');
|
||||
await flush();
|
||||
|
||||
// The registry cleared the dangling pointer, and the cleared pointer
|
||||
// persisted.
|
||||
expect(providers.getDefaultProvider()).toBeUndefined();
|
||||
expect(replaceSpy).toHaveBeenCalledWith(PROVIDERS_SECTION, {
|
||||
openai: { type: 'openai' },
|
||||
});
|
||||
expect(replaceSpy).toHaveBeenCalledWith(DEFAULT_PROVIDER_SECTION, undefined);
|
||||
expect(config.get(DEFAULT_PROVIDER_SECTION)).toBeUndefined();
|
||||
} finally {
|
||||
bridge.dispose();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('KosongConfigService env-pinned default pointer', () => {
|
||||
/**
|
||||
* Emulates an effective-overlay pin (e.g. `KIMI_MODEL_NAME` →
|
||||
* `defaultModel`): user-layer writes are accepted, but the effective read
|
||||
* (`get`) keeps returning the pinned value and no change event fires.
|
||||
*/
|
||||
class PinnedConfigService extends StubConfigService {
|
||||
constructor(
|
||||
private readonly pinnedDomain: string,
|
||||
pinnedValue: unknown,
|
||||
sections: Record<string, unknown>,
|
||||
) {
|
||||
super({ ...sections, [pinnedDomain]: pinnedValue });
|
||||
}
|
||||
|
||||
override replace(domain: string, value: unknown): Promise<void> {
|
||||
if (domain === this.pinnedDomain) return Promise.resolve();
|
||||
return super.replace(domain, value);
|
||||
}
|
||||
}
|
||||
|
||||
it('re-asserts the pinned effective default model into the registry after a registry-originated write', async () => {
|
||||
const config = new PinnedConfigService(DEFAULT_MODEL_SECTION, 'env-model', seededSections);
|
||||
const providers = new ProviderService();
|
||||
const models = new ModelService();
|
||||
const bridge = new KosongConfigService(config, providers, models, stubLogService());
|
||||
await bridge.ready;
|
||||
try {
|
||||
// Hydration reads the effective view: the pinned value, not the seeded one.
|
||||
expect(models.getDefaultModel()).toBe('env-model');
|
||||
const replaceSpy = vi.spyOn(config, 'replace');
|
||||
|
||||
await models.setDefaultModel('k1');
|
||||
await flush();
|
||||
|
||||
// The write landed in the user layer, but the pinned effective view
|
||||
// did not move, and the bridge reconciled the registry back to the pin.
|
||||
expect(replaceSpy).toHaveBeenCalledWith(DEFAULT_MODEL_SECTION, 'k1');
|
||||
expect(models.getDefaultModel()).toBe('env-model');
|
||||
} finally {
|
||||
bridge.dispose();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -1,25 +1,26 @@
|
|||
/**
|
||||
* `model` domain tests — covers `ModelService` CRUD over the `models` config
|
||||
* section, schema registration, and the delete-via-replace semantics.
|
||||
* `model` domain tests — covers `effectiveModelConfig`, the `models` config
|
||||
* section registration + TOML transforms (now owned by the app/kosongConfig
|
||||
* persistence wrapper), and the `KIMI_MODEL_*` env overlay.
|
||||
*
|
||||
* The registry itself (`ModelService`) is a pure in-memory store covered by
|
||||
* `test/kosong/model/modelService.test.ts`; persistence through the config
|
||||
* bridge is covered by `test/app/kosongConfig/kosongConfigService.test.ts`.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { DisposableStore } from '#/_base/di/lifecycle';
|
||||
import { createServices, type TestInstantiationService } from '#/_base/di/test';
|
||||
import { IConfigRegistry, IConfigService } from '#/app/config/config';
|
||||
import { ConfigRegistry } from '#/app/config/configService';
|
||||
import { ErrorCodes, Error2 } from '#/errors';
|
||||
import { kimiModelEnvOverlay, ENV_MODEL_ALIAS_KEY } from '#/kosong/model/envOverlay';
|
||||
import { kimiModelEnvOverlay, ENV_MODEL_ALIAS_KEY } from '#/app/kosongConfig/envOverlay';
|
||||
import {
|
||||
IModelService,
|
||||
type ModelRecord,
|
||||
ENV_MODEL_PROVIDER_KEY,
|
||||
MODELS_SECTION,
|
||||
ModelsSectionSchema,
|
||||
} from '#/kosong/model/model';
|
||||
import { modelsFromToml, modelsToToml } from '#/kosong/model/configSection';
|
||||
import { ModelService } from '#/kosong/model/modelService';
|
||||
import { ENV_MODEL_PROVIDER_KEY } from '#/kosong/provider/provider';
|
||||
modelsFromToml,
|
||||
modelsToToml,
|
||||
} from '#/app/kosongConfig/configSection';
|
||||
import { type ModelRecord } from '#/kosong/model/model';
|
||||
import { effectiveModelConfig } from '#/kosong/model/modelAuth';
|
||||
|
||||
// Side-effect registrations: endpoint defaults and the trait-driven-thinking
|
||||
|
|
@ -192,79 +193,9 @@ describe('effectiveModelConfig', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('ModelService', () => {
|
||||
let disposables: DisposableStore;
|
||||
let ix: TestInstantiationService;
|
||||
let registry: ConfigRegistry;
|
||||
let models: Record<string, ModelRecord>;
|
||||
let configSet: ReturnType<typeof vi.fn>;
|
||||
let configReplace: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
disposables = new DisposableStore();
|
||||
registry = new ConfigRegistry();
|
||||
models = {};
|
||||
configSet = vi.fn().mockResolvedValue(undefined);
|
||||
configReplace = vi.fn().mockResolvedValue(undefined);
|
||||
ix = createServices(disposables, {
|
||||
additionalServices: (reg) => {
|
||||
reg.defineInstance(IConfigRegistry, registry);
|
||||
reg.definePartialInstance(IConfigService, {
|
||||
get: ((domain: string) =>
|
||||
domain === MODELS_SECTION ? models : undefined) as IConfigService['get'],
|
||||
set: configSet as unknown as IConfigService['set'],
|
||||
replace: configReplace as unknown as IConfigService['replace'],
|
||||
onDidChangeConfiguration: (() => ({ dispose: () => { } })) as IConfigService['onDidChangeConfiguration'],
|
||||
});
|
||||
reg.define(IModelService, ModelService);
|
||||
},
|
||||
});
|
||||
});
|
||||
afterEach(() => disposables.dispose());
|
||||
|
||||
it('registers the models section schema from configSection import', () => {
|
||||
expect(registry.getSection(MODELS_SECTION)).toBeDefined();
|
||||
});
|
||||
|
||||
it('set delegates to config.set with a single-alias patch', async () => {
|
||||
const svc = ix.get(IModelService);
|
||||
await svc.set('m1', { provider: 'p', model: 'x', maxContextSize: 1000 });
|
||||
expect(configSet).toHaveBeenCalledWith(MODELS_SECTION, {
|
||||
m1: { provider: 'p', model: 'x', maxContextSize: 1000 },
|
||||
});
|
||||
});
|
||||
|
||||
it('get reads a single alias from config', () => {
|
||||
models['m1'] = { provider: 'p', model: 'x', maxContextSize: 1000 };
|
||||
const svc = ix.get(IModelService);
|
||||
expect(svc.get('m1')).toEqual({ provider: 'p', model: 'x', maxContextSize: 1000 });
|
||||
expect(svc.get('missing')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('list returns all aliases', () => {
|
||||
models['m1'] = { provider: 'p', model: 'x', maxContextSize: 1000 };
|
||||
models['m2'] = { provider: 'p', model: 'y', maxContextSize: 2000 };
|
||||
const svc = ix.get(IModelService);
|
||||
expect(svc.list()).toEqual({
|
||||
m1: { provider: 'p', model: 'x', maxContextSize: 1000 },
|
||||
m2: { provider: 'p', model: 'y', maxContextSize: 2000 },
|
||||
});
|
||||
});
|
||||
|
||||
it('delete removes the alias and replaces the whole section', async () => {
|
||||
models['m1'] = { provider: 'p', model: 'x', maxContextSize: 1000 };
|
||||
models['m2'] = { provider: 'p', model: 'y', maxContextSize: 2000 };
|
||||
const svc = ix.get(IModelService);
|
||||
await svc.delete('m1');
|
||||
expect(configReplace).toHaveBeenCalledWith(MODELS_SECTION, {
|
||||
m2: { provider: 'p', model: 'y', maxContextSize: 2000 },
|
||||
});
|
||||
});
|
||||
|
||||
it('delete is a no-op when the alias is absent', async () => {
|
||||
const svc = ix.get(IModelService);
|
||||
await svc.delete('missing');
|
||||
expect(configReplace).not.toHaveBeenCalled();
|
||||
describe('models config section', () => {
|
||||
it('self-registers the models section schema', () => {
|
||||
expect(new ConfigRegistry().getSection(MODELS_SECTION)).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,177 +1,34 @@
|
|||
/**
|
||||
* `provider` domain tests — covers `ProviderService` CRUD over the `providers`
|
||||
* config section, schema registration, and the delete-via-replace semantics.
|
||||
* `provider` config-section tests — the `providers` section registration
|
||||
* (schema + env bindings + strip hook, self-registered by the app/kosongConfig
|
||||
* persistence wrapper) and the TOML/env helper transforms.
|
||||
*
|
||||
* The registry itself (`ProviderService`) is a pure in-memory store covered
|
||||
* by `test/kosong/provider/providerService.test.ts`; persistence through the
|
||||
* config bridge is covered by `test/app/kosongConfig/kosongConfigService.test.ts`.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { DisposableStore } from '#/_base/di/lifecycle';
|
||||
import { Emitter } from '#/_base/event';
|
||||
import { createServices, type TestInstantiationService } from '#/_base/di/test';
|
||||
import { type ConfigChangedEvent, IConfigRegistry, IConfigService } from '#/app/config/config';
|
||||
import { ConfigRegistry } from '#/app/config/configService';
|
||||
import {
|
||||
ENV_MODEL_PROVIDER_KEY,
|
||||
PROVIDERS_SECTION,
|
||||
providersEnvBindings,
|
||||
providersFromToml,
|
||||
providersToToml,
|
||||
stripProvidersEnv,
|
||||
} from '#/kosong/provider/configSection';
|
||||
import {
|
||||
ENV_MODEL_PROVIDER_KEY,
|
||||
IProviderService,
|
||||
type ProviderConfig,
|
||||
PROVIDERS_SECTION,
|
||||
} from '#/kosong/provider/provider';
|
||||
import { ProviderService } from '#/kosong/provider/providerService';
|
||||
} from '#/app/kosongConfig/configSection';
|
||||
|
||||
describe('ProviderService', () => {
|
||||
let disposables: DisposableStore;
|
||||
let ix: TestInstantiationService;
|
||||
let registry: ConfigRegistry;
|
||||
let providers: Record<string, ProviderConfig>;
|
||||
let defaultProvider: string | undefined;
|
||||
let configSet: ReturnType<typeof vi.fn>;
|
||||
let configReplace: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
disposables = new DisposableStore();
|
||||
registry = new ConfigRegistry();
|
||||
providers = {};
|
||||
defaultProvider = undefined;
|
||||
configSet = vi.fn().mockResolvedValue(undefined);
|
||||
configReplace = vi.fn().mockResolvedValue(undefined);
|
||||
ix = createServices(disposables, {
|
||||
additionalServices: (reg) => {
|
||||
reg.defineInstance(IConfigRegistry, registry);
|
||||
reg.definePartialInstance(IConfigService, {
|
||||
get: ((domain: string) => {
|
||||
if (domain === PROVIDERS_SECTION) return providers;
|
||||
if (domain === 'defaultProvider') return defaultProvider;
|
||||
return undefined;
|
||||
}) as IConfigService['get'],
|
||||
set: configSet as unknown as IConfigService['set'],
|
||||
replace: configReplace as unknown as IConfigService['replace'],
|
||||
onDidChangeConfiguration: (() => ({ dispose: () => { } })) as IConfigService['onDidChangeConfiguration'],
|
||||
});
|
||||
reg.define(IProviderService, ProviderService);
|
||||
},
|
||||
});
|
||||
});
|
||||
afterEach(() => disposables.dispose());
|
||||
|
||||
it('registers the providers section schema on construction', () => {
|
||||
ix.get(IProviderService);
|
||||
describe('providers config section', () => {
|
||||
it('self-registers the schema with the env bindings and strip hook', () => {
|
||||
const registry = new ConfigRegistry();
|
||||
expect(registry.getSection(PROVIDERS_SECTION)).toMatchObject({
|
||||
domain: PROVIDERS_SECTION,
|
||||
env: providersEnvBindings,
|
||||
stripEnv: stripProvidersEnv,
|
||||
});
|
||||
});
|
||||
|
||||
it('set delegates to config.set with a single-provider patch', async () => {
|
||||
const svc = ix.get(IProviderService);
|
||||
await svc.set('p1', { type: 'openai', apiKey: 'sk' });
|
||||
expect(configSet).toHaveBeenCalledWith(PROVIDERS_SECTION, {
|
||||
p1: { type: 'openai', apiKey: 'sk' },
|
||||
});
|
||||
});
|
||||
|
||||
it('get reads a single provider from config', () => {
|
||||
providers['p1'] = { type: 'openai', apiKey: 'sk' };
|
||||
const svc = ix.get(IProviderService);
|
||||
expect(svc.get('p1')).toEqual({ type: 'openai', apiKey: 'sk' });
|
||||
expect(svc.get('missing')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('list returns all providers', () => {
|
||||
providers['p1'] = { type: 'openai' };
|
||||
providers['p2'] = { type: 'kimi' };
|
||||
const svc = ix.get(IProviderService);
|
||||
expect(svc.list()).toEqual({
|
||||
p1: { type: 'openai' },
|
||||
p2: { type: 'kimi' },
|
||||
});
|
||||
});
|
||||
|
||||
it('delete removes the provider and replaces the whole section', async () => {
|
||||
providers['p1'] = { type: 'openai' };
|
||||
providers['p2'] = { type: 'kimi' };
|
||||
const svc = ix.get(IProviderService);
|
||||
await svc.delete('p1');
|
||||
expect(configReplace).toHaveBeenCalledWith(PROVIDERS_SECTION, {
|
||||
p2: { type: 'kimi' },
|
||||
});
|
||||
});
|
||||
|
||||
it('delete is a no-op when the provider is absent', async () => {
|
||||
const svc = ix.get(IProviderService);
|
||||
await svc.delete('missing');
|
||||
expect(configReplace).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('delete clears defaultProvider when removing the default provider', async () => {
|
||||
providers['p1'] = { type: 'openai' };
|
||||
providers['p2'] = { type: 'kimi' };
|
||||
defaultProvider = 'p1';
|
||||
const svc = ix.get(IProviderService);
|
||||
await svc.delete('p1');
|
||||
expect(configReplace).toHaveBeenCalledWith(PROVIDERS_SECTION, {
|
||||
p2: { type: 'kimi' },
|
||||
});
|
||||
expect(configReplace).toHaveBeenCalledWith('defaultProvider', undefined);
|
||||
});
|
||||
|
||||
it('delete leaves defaultProvider when removing a different provider', async () => {
|
||||
providers['p1'] = { type: 'openai' };
|
||||
providers['p2'] = { type: 'kimi' };
|
||||
defaultProvider = 'p2';
|
||||
const svc = ix.get(IProviderService);
|
||||
await svc.delete('p1');
|
||||
expect(configReplace).not.toHaveBeenCalledWith('defaultProvider', expect.anything());
|
||||
});
|
||||
|
||||
it('forwards providers section changes as onDidChangeProviders with a diff', () => {
|
||||
const configEvents = disposables.add(new Emitter<ConfigChangedEvent>());
|
||||
const local = createServices(disposables, {
|
||||
additionalServices: (reg) => {
|
||||
reg.defineInstance(IConfigRegistry, registry);
|
||||
reg.definePartialInstance(IConfigService, {
|
||||
get: (() => undefined) as unknown as IConfigService['get'],
|
||||
onDidChangeConfiguration: configEvents.event,
|
||||
});
|
||||
reg.define(IProviderService, ProviderService);
|
||||
},
|
||||
});
|
||||
const svc = local.get(IProviderService);
|
||||
const diffs: { added: readonly string[]; removed: readonly string[]; changed: readonly string[] }[] = [];
|
||||
disposables.add(svc.onDidChangeProviders((e) => diffs.push(e)));
|
||||
|
||||
configEvents.fire({
|
||||
domain: PROVIDERS_SECTION,
|
||||
source: 'set',
|
||||
value: { p1: { type: 'openai' } },
|
||||
previousValue: {},
|
||||
});
|
||||
configEvents.fire({
|
||||
domain: PROVIDERS_SECTION,
|
||||
source: 'set',
|
||||
value: { p1: { type: 'kimi' } },
|
||||
previousValue: { p1: { type: 'openai' } },
|
||||
});
|
||||
configEvents.fire({
|
||||
domain: PROVIDERS_SECTION,
|
||||
source: 'set',
|
||||
value: {},
|
||||
previousValue: { p1: { type: 'kimi' } },
|
||||
});
|
||||
configEvents.fire({ domain: 'models', source: 'set', value: {}, previousValue: {} });
|
||||
|
||||
expect(diffs).toEqual([
|
||||
{ added: ['p1'], removed: [], changed: [] },
|
||||
{ added: [], removed: [], changed: ['p1'] },
|
||||
{ added: [], removed: ['p1'], changed: [] },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('provider config section helpers', () => {
|
||||
|
|
|
|||
|
|
@ -14,9 +14,14 @@ export function stubProviderService(
|
|||
_serviceBrand: undefined,
|
||||
ready,
|
||||
onDidChangeProviders: () => ({ dispose: () => {} }),
|
||||
onDidChangeDefaultProvider: () => ({ dispose: () => {} }),
|
||||
get: (name: string) => providers[name],
|
||||
list: () => providers,
|
||||
getDefaultProvider: () => undefined,
|
||||
set: async () => {},
|
||||
delete: async () => {},
|
||||
loadAll: () => {},
|
||||
replaceAll: async () => {},
|
||||
setDefaultProvider: async () => {},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -64,7 +64,6 @@ import type {
|
|||
} from '#/tool/toolContract';
|
||||
import { AGENT_WIRE_RECORD_KEY, wireRecordToPayload, type WireRecord } from '#/wire/record';
|
||||
import { OP_REGISTRY } from '#/wire/op';
|
||||
import { IOAuthService } from '#/app/auth/auth';
|
||||
import { IProtocolAdapterRegistry, type ProtocolAdapterConfig } from '#/kosong/protocol/protocol';
|
||||
import { ProtocolAdapterRegistry } from '#/kosong/provider/protocolAdapterRegistry';
|
||||
import { hasProviderDefinition } from '#/kosong/provider/providerDefinition';
|
||||
|
|
@ -145,12 +144,23 @@ import {
|
|||
import { IEventBus } from '#/app/event/eventBus';
|
||||
import { IWireService } from '#/wire/wire';
|
||||
import { WireService } from '#/wire/wireService';
|
||||
import { IModelService } from '#/kosong/model/model';
|
||||
import { IModelService, type ModelsSection } from '#/kosong/model/model';
|
||||
import {
|
||||
DEFAULT_MODEL_SECTION,
|
||||
DEFAULT_PROVIDER_SECTION,
|
||||
MODELS_SECTION,
|
||||
PROVIDERS_SECTION,
|
||||
} from '#/app/kosongConfig/configSection';
|
||||
import { IModelCatalog, type Model } from '#/kosong/model/catalog';
|
||||
import { ModelCatalog } from '#/kosong/model/catalogService';
|
||||
import { IModelOAuthTokens } from '#/kosong/model/modelOAuth';
|
||||
import type { ModelRequestParams, ModelRequester } from '#/kosong/model/modelRequester';
|
||||
import { IHostRequestHeaders } from '#/kosong/model/hostRequestHeaders';
|
||||
import { IProviderService, type ProviderConfig } from '#/kosong/provider/provider';
|
||||
import {
|
||||
IProviderService,
|
||||
type ProviderConfig,
|
||||
type ProvidersSection,
|
||||
} from '#/kosong/provider/provider';
|
||||
import type { ApprovalResponse } from '#/session/approval/approval';
|
||||
import {
|
||||
ISessionInteractionService,
|
||||
|
|
@ -895,17 +905,43 @@ class PersistenceAppendLogStore implements IAppendLogStore {
|
|||
class ConfigBackedModelCatalog extends ModelCatalog {
|
||||
constructor(
|
||||
private readonly options: TestModelProviderOptions = {},
|
||||
@IConfigService config: IConfigService,
|
||||
@IProviderService providers: IProviderService,
|
||||
@IModelService models: IModelService,
|
||||
@IOAuthService oauth: IOAuthService,
|
||||
@IConfigService private readonly config: IConfigService,
|
||||
@IProviderService private readonly providerRegistry: IProviderService,
|
||||
@IModelService private readonly modelRegistry: IModelService,
|
||||
@IModelOAuthTokens oauthTokens: IModelOAuthTokens,
|
||||
@IProtocolAdapterRegistry protocolRegistry: IProtocolAdapterRegistry,
|
||||
@IHostRequestHeaders hostRequestHeaders: IHostRequestHeaders,
|
||||
) {
|
||||
super(config, providers, models, oauth, protocolRegistry, hostRequestHeaders);
|
||||
super(providerRegistry, modelRegistry, oauthTokens, protocolRegistry, hostRequestHeaders);
|
||||
}
|
||||
|
||||
/**
|
||||
* The harness mutates `kimiConfig` BEHIND the config services' backs (no
|
||||
* section-change events fire), so nothing pushes the new values into the
|
||||
* kosong registries. Re-hydrate them from the live config view before every
|
||||
* read: `loadAll` is deep-equal-aware, so an unchanged config is a no-op
|
||||
* and a changed one fires the diff events that drop the assembled-Model
|
||||
* cache — preserving the old read-config-live semantics through the new
|
||||
* in-memory registries.
|
||||
*/
|
||||
private syncRegistriesFromConfig(): void {
|
||||
this.providerRegistry.loadAll(
|
||||
this.config.get<ProvidersSection>(PROVIDERS_SECTION) ?? {},
|
||||
this.config.get<string>(DEFAULT_PROVIDER_SECTION),
|
||||
);
|
||||
this.modelRegistry.loadAll(
|
||||
this.config.get<ModelsSection>(MODELS_SECTION) ?? {},
|
||||
this.config.get<string>(DEFAULT_MODEL_SECTION),
|
||||
);
|
||||
}
|
||||
|
||||
override get(id: string): Model {
|
||||
this.syncRegistriesFromConfig();
|
||||
return super.get(id);
|
||||
}
|
||||
|
||||
override getRequester(id: string): ModelRequester {
|
||||
this.syncRegistriesFromConfig();
|
||||
const requester = super.getRequester(id);
|
||||
const cacheKey = this.options.promptCacheKey;
|
||||
if (cacheKey === undefined) return requester;
|
||||
|
|
@ -918,6 +954,11 @@ class ConfigBackedModelCatalog extends ModelCatalog {
|
|||
) => requester.request(input, signal, { cacheKey, ...params }),
|
||||
};
|
||||
}
|
||||
|
||||
override findByName(name: string): readonly string[] {
|
||||
this.syncRegistriesFromConfig();
|
||||
return super.findByName(name);
|
||||
}
|
||||
}
|
||||
|
||||
function renderPluginSessionStartReminder(
|
||||
|
|
@ -1024,6 +1065,19 @@ export class AgentTestContext {
|
|||
options.generate ?? this.scriptedGenerate.generate,
|
||||
),
|
||||
);
|
||||
reg.defineInstance(
|
||||
IModelOAuthTokens,
|
||||
{
|
||||
_serviceBrand: undefined,
|
||||
hasCachedAccessToken: () => Promise.resolve(false),
|
||||
getAccessToken: () =>
|
||||
Promise.reject(
|
||||
new Error(
|
||||
'IModelOAuthTokens.getAccessToken is not supported in the test harness',
|
||||
),
|
||||
),
|
||||
} satisfies IModelOAuthTokens,
|
||||
);
|
||||
reg.defineDescriptor(
|
||||
IModelCatalog,
|
||||
new SyncDescriptor(ConfigBackedModelCatalog, [{}]),
|
||||
|
|
@ -1060,6 +1114,24 @@ export class AgentTestContext {
|
|||
);
|
||||
this.root = createAppScope({ extra: appSeeds });
|
||||
|
||||
// Hydrate the kosong registries from the (possibly overridden) config so
|
||||
// direct IProviderService/IModelService reads work before the first
|
||||
// catalog access; ConfigBackedModelCatalog re-syncs on every read after
|
||||
// that (the harness mutates kimiConfig behind the config events' backs).
|
||||
const initialConfig = this.root.accessor.get(IConfigService);
|
||||
this.root.accessor
|
||||
.get(IProviderService)
|
||||
.loadAll(
|
||||
initialConfig.get<ProvidersSection>(PROVIDERS_SECTION) ?? {},
|
||||
initialConfig.get<string>(DEFAULT_PROVIDER_SECTION),
|
||||
);
|
||||
this.root.accessor
|
||||
.get(IModelService)
|
||||
.loadAll(
|
||||
initialConfig.get<ModelsSection>(MODELS_SECTION) ?? {},
|
||||
initialConfig.get<string>(DEFAULT_MODEL_SECTION),
|
||||
);
|
||||
|
||||
const bootstrap = this.root.accessor.get(IBootstrapService);
|
||||
const workspaceId = 'test-workspace';
|
||||
const agentTelemetry = this.root.accessor
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@
|
|||
* definition's `hostHeaders`, and the Anthropic effort profile is inferred
|
||||
* only for vendors whose thinking is not trait-driven;
|
||||
* - `get`/`getRequester` cache per id; the cache drops on the
|
||||
* model/provider change events — and ONLY there: a config write
|
||||
* model/provider change events — and ONLY there: a registry write
|
||||
* that bypasses the events keeps serving the stale Model until
|
||||
* `notifyConfigChanged()` (the load-bearing test-harness contract).
|
||||
*/
|
||||
|
|
@ -23,7 +23,6 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|||
import { createScopedTestHost } from '#/_base/di/test';
|
||||
import { isErrorCode } from '#/_base/errors/codes';
|
||||
import { isError2 } from '#/_base/errors/errors';
|
||||
import { IOAuthService } from '#/app/auth/auth';
|
||||
import { IConfigService } from '#/app/config/config';
|
||||
import { ConfigErrors } from '#/app/config/errors';
|
||||
import { UNKNOWN_CAPABILITY } from '#/kosong/contract/capability';
|
||||
|
|
@ -36,7 +35,11 @@ import '#/kosong/provider/bases/openai/index';
|
|||
import '#/kosong/provider/protocolAdapterRegistry';
|
||||
import '#/kosong/provider/providers/kimi/kimi.contrib';
|
||||
import '#/kosong/provider/providers/standard.contrib';
|
||||
import { IProviderService, type ProviderConfig } from '#/kosong/provider/provider';
|
||||
import {
|
||||
IProviderService,
|
||||
type ProviderConfig,
|
||||
type ProvidersSection,
|
||||
} from '#/kosong/provider/provider';
|
||||
import '#/kosong/provider/providerService';
|
||||
import {
|
||||
globalDefaultForProvider,
|
||||
|
|
@ -50,16 +53,17 @@ import {
|
|||
import { ModelCatalog } from '#/kosong/model/catalogService';
|
||||
import '#/kosong/model/errors';
|
||||
import { HostRequestHeaders, IHostRequestHeaders } from '#/kosong/model/hostRequestHeaders';
|
||||
import { IModelService, type ModelRecord } from '#/kosong/model/model';
|
||||
import { IModelService, type ModelRecord, type ModelsSection } from '#/kosong/model/model';
|
||||
import '#/kosong/model/modelService';
|
||||
import { IModelOAuthTokens } from '#/kosong/model/modelOAuth';
|
||||
|
||||
import { StubConfigService, stubOAuthService, stubTokenProvider } from '../stubs';
|
||||
import { StubConfigService, stubModelOAuthTokens, stubTokenProvider } from '../stubs';
|
||||
|
||||
const HOST_HEADERS = { 'User-Agent': 'kimi-test/1.0', 'X-Msh-Device-Id': 'device-1' };
|
||||
|
||||
function createHost(
|
||||
sections: Record<string, unknown> = {},
|
||||
oauth: IOAuthService = stubOAuthService(),
|
||||
oauthTokens: IModelOAuthTokens = stubModelOAuthTokens(),
|
||||
): {
|
||||
host: ReturnType<typeof createScopedTestHost>;
|
||||
config: StubConfigService;
|
||||
|
|
@ -70,15 +74,27 @@ function createHost(
|
|||
const config = new StubConfigService(sections);
|
||||
const host = createScopedTestHost([
|
||||
[IConfigService, config],
|
||||
[IOAuthService, oauth],
|
||||
[IModelOAuthTokens, oauthTokens],
|
||||
[IHostRequestHeaders, new HostRequestHeaders(HOST_HEADERS)],
|
||||
]);
|
||||
// Kosong's registries are pure in-memory stores now (persistence lives in
|
||||
// the app/kosongConfig bridge): seed them from the fixture sections.
|
||||
const providers = host.app.accessor.get(IProviderService);
|
||||
providers.loadAll(
|
||||
(sections['providers'] ?? {}) as ProvidersSection,
|
||||
sections['defaultProvider'] as string | undefined,
|
||||
);
|
||||
const models = host.app.accessor.get(IModelService);
|
||||
models.loadAll(
|
||||
(sections['models'] ?? {}) as ModelsSection,
|
||||
sections['defaultModel'] as string | undefined,
|
||||
);
|
||||
return {
|
||||
host,
|
||||
config,
|
||||
catalog: host.app.accessor.get(IModelCatalog) as ModelCatalog,
|
||||
models: host.app.accessor.get(IModelService),
|
||||
providers: host.app.accessor.get(IProviderService),
|
||||
models,
|
||||
providers,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -91,6 +107,16 @@ const kimiSections: Record<string, unknown> = {
|
|||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Mutate the model registry store WITHOUT firing the change events — the
|
||||
* silent-write escape hatch for the cache-invalidation tests (replaces the
|
||||
* old `StubConfigService.setSilent`, which the in-memory registries can no
|
||||
* longer see).
|
||||
*/
|
||||
function silentModelWrite(models: IModelService, records: Record<string, ModelRecord>): void {
|
||||
(models as unknown as { models: Record<string, ModelRecord> }).models = records;
|
||||
}
|
||||
|
||||
let savedCustomHeaders: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
|
|
@ -389,19 +415,17 @@ describe('Model assembly (pure data)', () => {
|
|||
|
||||
it('builds a refreshable OAuth auth provider for oauth-backed models', async () => {
|
||||
const tokenProvider = stubTokenProvider(['tok-1']);
|
||||
const config = new StubConfigService({
|
||||
providers: {
|
||||
kimi: { type: 'kimi', oauth: { storage: 'file', key: 'kimi' }, baseUrl: 'https://api.moonshot.ai/v1' },
|
||||
const { host, catalog } = createHost(
|
||||
{
|
||||
providers: {
|
||||
kimi: { type: 'kimi', oauth: { storage: 'file', key: 'kimi' }, baseUrl: 'https://api.moonshot.ai/v1' },
|
||||
},
|
||||
models: { k1: { provider: 'kimi', model: 'kimi-k2', maxContextSize: 1 } },
|
||||
},
|
||||
models: { k1: { provider: 'kimi', model: 'kimi-k2', maxContextSize: 1 } },
|
||||
});
|
||||
const host = createScopedTestHost([
|
||||
[IConfigService, config],
|
||||
[IOAuthService, stubOAuthService(tokenProvider)],
|
||||
[IHostRequestHeaders, new HostRequestHeaders({})],
|
||||
]);
|
||||
stubModelOAuthTokens(tokenProvider),
|
||||
);
|
||||
try {
|
||||
const model = (host.app.accessor.get(IModelCatalog) as ModelCatalog).get('k1');
|
||||
const model = catalog.get('k1');
|
||||
expect(model.authProvider.canRefresh).toBe(true);
|
||||
await expect(model.authProvider.getAuth()).resolves.toEqual({ apiKey: 'tok-1' });
|
||||
} finally {
|
||||
|
|
@ -440,14 +464,14 @@ describe('ModelCatalog caching and config-event invalidation', () => {
|
|||
}
|
||||
});
|
||||
|
||||
it('keeps serving the stale Model on a silent config write until notifyConfigChanged()', async () => {
|
||||
const { host, catalog, config } = createHost(kimiSections);
|
||||
it('keeps serving the stale Model on a silent registry write until notifyConfigChanged()', async () => {
|
||||
const { host, catalog, models } = createHost(kimiSections);
|
||||
try {
|
||||
const before = catalog.get('k1');
|
||||
|
||||
// Bypass the change events entirely: the catalog cache is the only
|
||||
// stale layer, and only an explicit notify drops it.
|
||||
config.setSilent('models', {
|
||||
silentModelWrite(models, {
|
||||
k1: { provider: 'kimi', model: 'kimi-k2', maxContextSize: 262144, displayName: 'silent' },
|
||||
});
|
||||
expect(catalog.get('k1')).toBe(before);
|
||||
|
|
@ -520,16 +544,16 @@ describe('ModelCatalog inspect', () => {
|
|||
});
|
||||
|
||||
it('serves the same resolution as get (chain consistency, same cache generation)', () => {
|
||||
const { host, catalog, config } = createHost(kimiSections);
|
||||
const { host, catalog, models } = createHost(kimiSections);
|
||||
try {
|
||||
const model = catalog.get('k1');
|
||||
const view = catalog.inspect('k1');
|
||||
const { authProvider: _auth, id: _id, name, ...rest } = model;
|
||||
expect(view.resolved).toMatchObject({ ...rest, wireName: name });
|
||||
|
||||
// A silent config write keeps the stale generation: inspect reflects
|
||||
// A silent registry write keeps the stale generation: inspect reflects
|
||||
// THAT generation (what get keeps serving), never a re-resolution.
|
||||
config.setSilent('models', {
|
||||
silentModelWrite(models, {
|
||||
k1: { provider: 'kimi', model: 'kimi-k2', maxContextSize: 262144, displayName: 'silent' },
|
||||
});
|
||||
expect(catalog.inspect('k1').resolved.displayName).toBeUndefined();
|
||||
|
|
@ -743,12 +767,7 @@ describe('ModelCatalog inspect', () => {
|
|||
|
||||
describe('ModelCatalog ping', () => {
|
||||
it('returns the streamed text and usage on a live success', async () => {
|
||||
const config = new StubConfigService(kimiSections);
|
||||
const host = createScopedTestHost([
|
||||
[IConfigService, config],
|
||||
[IOAuthService, stubOAuthService()],
|
||||
[IHostRequestHeaders, new HostRequestHeaders({})],
|
||||
]);
|
||||
const { host, models, providers } = createHost(kimiSections, stubModelOAuthTokens());
|
||||
try {
|
||||
const fakeProvider: ChatProvider = {
|
||||
name: 'fake-base',
|
||||
|
|
@ -784,10 +803,9 @@ describe('ModelCatalog ping', () => {
|
|||
createChatProvider: () => fakeProvider,
|
||||
} as unknown as IProtocolAdapterRegistry;
|
||||
const catalog = new ModelCatalog(
|
||||
config,
|
||||
host.app.accessor.get(IProviderService),
|
||||
host.app.accessor.get(IModelService),
|
||||
host.app.accessor.get(IOAuthService),
|
||||
providers,
|
||||
models,
|
||||
stubModelOAuthTokens(),
|
||||
registry,
|
||||
new HostRequestHeaders({}),
|
||||
);
|
||||
|
|
@ -1199,16 +1217,12 @@ describe('ModelCatalog enumeration', () => {
|
|||
});
|
||||
|
||||
it('marks an OAuth provider connected when a cached token exists', async () => {
|
||||
const oauth = {
|
||||
...stubOAuthService(),
|
||||
getCachedAccessToken: async () => 'cached-token',
|
||||
} as unknown as IOAuthService;
|
||||
const { host, catalog } = createHost(
|
||||
{
|
||||
providers: { acme: { type: 'kimi', oauth: { storage: 'file', key: 'oauth/acme' } } },
|
||||
models: {},
|
||||
},
|
||||
oauth,
|
||||
stubModelOAuthTokens(undefined, 'cached-token'),
|
||||
);
|
||||
try {
|
||||
const [provider] = await catalog.listProviders();
|
||||
|
|
@ -1238,8 +1252,8 @@ describe('ModelCatalog enumeration', () => {
|
|||
});
|
||||
|
||||
describe('ModelCatalog setDefaultModel', () => {
|
||||
it('persists through config and returns the wire model', async () => {
|
||||
const { host, config, catalog } = createHost(catalogSections);
|
||||
it('moves the registry default pointer and returns the wire model', async () => {
|
||||
const { host, models, catalog } = createHost(catalogSections);
|
||||
try {
|
||||
await expect(catalog.setDefaultModel('turbo')).resolves.toEqual({
|
||||
default_model: 'turbo',
|
||||
|
|
@ -1250,7 +1264,9 @@ describe('ModelCatalog setDefaultModel', () => {
|
|||
max_context_size: 32768,
|
||||
},
|
||||
});
|
||||
expect(config.get<string>('defaultModel')).toBe('turbo');
|
||||
// The catalog writes the in-memory pointer; persisting it to config is
|
||||
// the app/kosongConfig bridge's job.
|
||||
expect(models.getDefaultModel()).toBe('turbo');
|
||||
} finally {
|
||||
host.dispose();
|
||||
}
|
||||
|
|
@ -1268,7 +1284,7 @@ describe('ModelCatalog setDefaultModel', () => {
|
|||
});
|
||||
|
||||
it('rejects a model that fails materialization', async () => {
|
||||
const { host, config, catalog } = createHost({
|
||||
const { host, models, catalog } = createHost({
|
||||
providers: {},
|
||||
models: {
|
||||
bad: {
|
||||
|
|
@ -1281,7 +1297,7 @@ describe('ModelCatalog setDefaultModel', () => {
|
|||
});
|
||||
try {
|
||||
await expect(catalog.setDefaultModel('bad')).rejects.toThrow();
|
||||
expect(config.get('defaultModel')).toBeUndefined();
|
||||
expect(models.getDefaultModel()).toBeUndefined();
|
||||
} finally {
|
||||
host.dispose();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,19 +4,16 @@
|
|||
*
|
||||
* - the section TOML transforms round-trip snake_case ↔ camelCase (including
|
||||
* the nested `overrides` object);
|
||||
* - `ModelService` CRUD persists through config and diffs section changes
|
||||
* into `onDidChangeModels` (added/changed/removed).
|
||||
* - `ModelService` is an in-memory registry: `loadAll` hydrates and resolves
|
||||
* `ready`, CRUD diffs state changes into `onDidChangeModels`
|
||||
* (added/changed/removed), and equal writes stay silent.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { createScopedTestHost } from '#/_base/di/test';
|
||||
import { IConfigService } from '#/app/config/config';
|
||||
import { modelsFromToml, modelsToToml } from '#/kosong/model/configSection';
|
||||
import { IModelService, type ModelRecord } from '#/kosong/model/model';
|
||||
import '#/kosong/model/modelService';
|
||||
|
||||
import { StubConfigService } from '../stubs';
|
||||
import { modelsFromToml, modelsToToml } from '#/app/kosongConfig/configSection';
|
||||
import { type ModelRecord } from '#/kosong/model/model';
|
||||
import { ModelService } from '#/kosong/model/modelService';
|
||||
|
||||
describe('models TOML transforms', () => {
|
||||
it('converts snake_case entries to camelCase and back', () => {
|
||||
|
|
@ -67,46 +64,76 @@ describe('models TOML transforms', () => {
|
|||
});
|
||||
|
||||
describe('ModelService', () => {
|
||||
function createHost(): {
|
||||
host: ReturnType<typeof createScopedTestHost>;
|
||||
service: IModelService;
|
||||
} {
|
||||
const config = new StubConfigService();
|
||||
const host = createScopedTestHost([[IConfigService, config]]);
|
||||
const service = host.app.accessor.get(IModelService);
|
||||
return { host, service };
|
||||
function createService(models: Readonly<Record<string, ModelRecord>> = {}): ModelService {
|
||||
const service = new ModelService();
|
||||
service.loadAll({ ...models }, undefined);
|
||||
return service;
|
||||
}
|
||||
|
||||
it('supports CRUD and diffs section changes into onDidChangeModels', async () => {
|
||||
const { host, service } = createHost();
|
||||
try {
|
||||
const events: Array<{
|
||||
added: readonly string[];
|
||||
removed: readonly string[];
|
||||
changed: readonly string[];
|
||||
}> = [];
|
||||
service.onDidChangeModels((e) => events.push(e));
|
||||
it('resolves ready on the first loadAll and exposes the default pointer', async () => {
|
||||
const service = new ModelService();
|
||||
let ready = false;
|
||||
void service.ready.then(() => {
|
||||
ready = true;
|
||||
});
|
||||
await Promise.resolve();
|
||||
expect(ready).toBe(false);
|
||||
|
||||
const k1: ModelRecord = { provider: 'moonshot', model: 'kimi-k2', maxContextSize: 262144 };
|
||||
await service.set('k1', k1);
|
||||
expect(service.get('k1')).toEqual(k1);
|
||||
expect(service.list()).toEqual({ k1 });
|
||||
expect(events).toEqual([{ added: ['k1'], removed: [], changed: [] }]);
|
||||
service.loadAll({ k1: { model: 'kimi-k2', maxContextSize: 262144 } }, 'k1');
|
||||
await service.ready;
|
||||
expect(ready).toBe(true);
|
||||
expect(service.getDefaultModel()).toBe('k1');
|
||||
});
|
||||
|
||||
const updated: ModelRecord = { ...k1, displayName: 'K2' };
|
||||
await service.set('k1', updated);
|
||||
expect(events.at(-1)).toEqual({ added: [], removed: [], changed: ['k1'] });
|
||||
it('supports CRUD and diffs state changes into onDidChangeModels', async () => {
|
||||
const service = createService();
|
||||
const events: Array<{
|
||||
added: readonly string[];
|
||||
removed: readonly string[];
|
||||
changed: readonly string[];
|
||||
}> = [];
|
||||
service.onDidChangeModels((e) =>
|
||||
events.push({ added: e.added, removed: e.removed, changed: e.changed }),
|
||||
);
|
||||
|
||||
// Rewriting with an identical record still fires the config event but
|
||||
// diffs to no changed keys.
|
||||
await service.set('k1', updated);
|
||||
expect(events.at(-1)).toEqual({ added: [], removed: [], changed: [] });
|
||||
const k1: ModelRecord = { provider: 'moonshot', model: 'kimi-k2', maxContextSize: 262144 };
|
||||
await service.set('k1', k1);
|
||||
expect(service.get('k1')).toEqual(k1);
|
||||
expect(service.list()).toEqual({ k1 });
|
||||
expect(events).toEqual([{ added: ['k1'], removed: [], changed: [] }]);
|
||||
|
||||
await service.delete('k1');
|
||||
expect(service.get('k1')).toBeUndefined();
|
||||
expect(events.at(-1)).toEqual({ added: [], removed: ['k1'], changed: [] });
|
||||
} finally {
|
||||
host.dispose();
|
||||
}
|
||||
const updated: ModelRecord = { ...k1, displayName: 'K2' };
|
||||
await service.set('k1', updated);
|
||||
expect(events.at(-1)).toEqual({ added: [], removed: [], changed: ['k1'] });
|
||||
|
||||
// Rewriting with an identical record is silent — no event fires.
|
||||
await service.set('k1', updated);
|
||||
expect(events).toHaveLength(2);
|
||||
|
||||
await service.delete('k1');
|
||||
expect(service.get('k1')).toBeUndefined();
|
||||
expect(events.at(-1)).toEqual({ added: [], removed: ['k1'], changed: [] });
|
||||
});
|
||||
|
||||
it('replaceAll replaces the records and keeps the default pointer', async () => {
|
||||
const service = createService({ a: { model: 'm-a' }, b: { model: 'm-b' } });
|
||||
await service.setDefaultModel('a');
|
||||
|
||||
await service.replaceAll({ c: { model: 'm-c' } });
|
||||
expect(service.list()).toEqual({ c: { model: 'm-c' } });
|
||||
expect(service.getDefaultModel()).toBe('a');
|
||||
});
|
||||
|
||||
it('fires the pointer event only on real pointer changes', async () => {
|
||||
const service = createService({ k1: { model: 'kimi-k2' } });
|
||||
const pointerEvents: Array<string | undefined> = [];
|
||||
service.onDidChangeDefaultModel((e) => pointerEvents.push(e.id));
|
||||
|
||||
await service.setDefaultModel('k1');
|
||||
await service.setDefaultModel('k1');
|
||||
expect(pointerEvents).toEqual(['k1']);
|
||||
|
||||
await service.setDefaultModel(undefined);
|
||||
expect(pointerEvents).toEqual(['k1', undefined]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -5,86 +5,21 @@
|
|||
* - `ProviderTypeSchema` is free-form text: unregistered vendor names parse
|
||||
* (validation happens at resolve time, not parse time);
|
||||
* - the section TOML transforms round-trip snake_case ↔ camelCase;
|
||||
* - `ProviderService` CRUD persists through config, diffs section changes
|
||||
* into `onDidChangeProviders` (added/changed/removed), and clears
|
||||
* `defaultProvider` when the default provider is deleted.
|
||||
* - `ProviderService` is an in-memory registry: `loadAll` hydrates and
|
||||
* resolves `ready`, CRUD diffs state changes into `onDidChangeProviders`
|
||||
* (added/changed/removed), equal writes stay silent, and deleting the
|
||||
* default provider clears the `defaultProvider` pointer.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { createScopedTestHost } from '#/_base/di/test';
|
||||
import { Emitter, type Event } from '#/_base/event';
|
||||
import {
|
||||
type ConfigChangedEvent,
|
||||
type ConfigDiagnostic,
|
||||
type ConfigInspectValue,
|
||||
IConfigService,
|
||||
type ResolvedConfig,
|
||||
} from '#/app/config/config';
|
||||
import { providersFromToml, providersToToml } from '#/kosong/provider/configSection';
|
||||
import '#/kosong/provider/providerService';
|
||||
import {
|
||||
DEFAULT_PROVIDER_SECTION,
|
||||
IProviderService,
|
||||
type ProviderConfig,
|
||||
ProvidersSectionSchema,
|
||||
} from '#/kosong/provider/provider';
|
||||
|
||||
class StubConfigService implements IConfigService {
|
||||
declare readonly _serviceBrand: undefined;
|
||||
readonly ready = Promise.resolve();
|
||||
private readonly _onDidChange = new Emitter<ConfigChangedEvent>();
|
||||
readonly onDidChangeConfiguration: Event<ConfigChangedEvent> = this._onDidChange.event;
|
||||
readonly onDidSectionChange: Event<ConfigChangedEvent> = this._onDidChange.event;
|
||||
private readonly _values = new Map<string, unknown>();
|
||||
|
||||
get<T = unknown>(domain: string): T {
|
||||
return this._values.get(domain) as T;
|
||||
}
|
||||
|
||||
inspect<T = unknown>(domain: string): ConfigInspectValue<T> {
|
||||
return {
|
||||
value: this._values.get(domain) as T | undefined,
|
||||
defaultValue: undefined,
|
||||
userValue: undefined,
|
||||
memoryValue: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
getAll(): ResolvedConfig {
|
||||
return Object.fromEntries(this._values) as ResolvedConfig;
|
||||
}
|
||||
|
||||
set(domain: string, patch: unknown): Promise<void> {
|
||||
const previousValue = this._values.get(domain);
|
||||
const value =
|
||||
patch !== null && typeof patch === 'object'
|
||||
? { ...(previousValue as Record<string, unknown> | undefined), ...patch }
|
||||
: patch;
|
||||
this._values.set(domain, value);
|
||||
this._onDidChange.fire({ domain, source: 'set', value, previousValue });
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
replace(domain: string, value: unknown): Promise<void> {
|
||||
const previousValue = this._values.get(domain);
|
||||
if (value === undefined) {
|
||||
this._values.delete(domain);
|
||||
} else {
|
||||
this._values.set(domain, value);
|
||||
}
|
||||
this._onDidChange.fire({ domain, source: 'set', value, previousValue });
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
reload(): Promise<void> {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
diagnostics(): readonly ConfigDiagnostic[] {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
providersFromToml,
|
||||
providersToToml,
|
||||
} from '#/app/kosongConfig/configSection';
|
||||
import { ProviderService } from '#/kosong/provider/providerService';
|
||||
import { type ProviderConfig } from '#/kosong/provider/provider';
|
||||
|
||||
describe('ProviderTypeSchema (free-form vendor identity)', () => {
|
||||
it('parses unregistered vendor names — resolve-time validation, not parse-time', () => {
|
||||
|
|
@ -126,61 +61,106 @@ describe('providers TOML transforms', () => {
|
|||
});
|
||||
|
||||
describe('ProviderService', () => {
|
||||
function createHost(): {
|
||||
host: ReturnType<typeof createScopedTestHost>;
|
||||
service: IProviderService;
|
||||
config: StubConfigService;
|
||||
} {
|
||||
const config = new StubConfigService();
|
||||
const host = createScopedTestHost([[IConfigService, config]]);
|
||||
const service = host.app.accessor.get(IProviderService);
|
||||
return { host, service, config };
|
||||
function createService(providers: Readonly<Record<string, ProviderConfig>> = {}): ProviderService {
|
||||
const service = new ProviderService();
|
||||
service.loadAll({ ...providers }, undefined);
|
||||
return service;
|
||||
}
|
||||
|
||||
it('supports CRUD and diffs section changes into onDidChangeProviders', async () => {
|
||||
const { host, service } = createHost();
|
||||
try {
|
||||
const events: Array<{
|
||||
added: readonly string[];
|
||||
removed: readonly string[];
|
||||
changed: readonly string[];
|
||||
}> = [];
|
||||
service.onDidChangeProviders((e) => events.push(e));
|
||||
it('resolves ready on the first loadAll and gates mutations on it', async () => {
|
||||
const service = new ProviderService();
|
||||
let ready = false;
|
||||
void service.ready.then(() => {
|
||||
ready = true;
|
||||
});
|
||||
await Promise.resolve();
|
||||
expect(ready).toBe(false);
|
||||
|
||||
const moonshot: ProviderConfig = { type: 'kimi', baseUrl: 'https://api.moonshot.ai/v1' };
|
||||
await service.set('moonshot', moonshot);
|
||||
expect(service.get('moonshot')).toEqual(moonshot);
|
||||
expect(service.list()).toEqual({ moonshot });
|
||||
expect(events).toEqual([{ added: ['moonshot'], removed: [], changed: [] }]);
|
||||
|
||||
const updated: ProviderConfig = { ...moonshot, apiKey: 'sk-1' };
|
||||
await service.set('moonshot', updated);
|
||||
expect(events.at(-1)).toEqual({ added: [], removed: [], changed: ['moonshot'] });
|
||||
|
||||
// Rewriting with an identical record still fires the config event but
|
||||
// diffs to no changed keys.
|
||||
await service.set('moonshot', updated);
|
||||
expect(events.at(-1)).toEqual({ added: [], removed: [], changed: [] });
|
||||
|
||||
await service.delete('moonshot');
|
||||
expect(service.get('moonshot')).toBeUndefined();
|
||||
expect(events.at(-1)).toEqual({ added: [], removed: ['moonshot'], changed: [] });
|
||||
} finally {
|
||||
host.dispose();
|
||||
}
|
||||
service.loadAll({ moonshot: { type: 'kimi' } }, 'moonshot');
|
||||
await service.ready;
|
||||
expect(ready).toBe(true);
|
||||
expect(service.get('moonshot')).toEqual({ type: 'kimi' });
|
||||
expect(service.getDefaultProvider()).toBe('moonshot');
|
||||
});
|
||||
|
||||
it('clears defaultProvider when the default provider is deleted', async () => {
|
||||
const { host, service, config } = createHost();
|
||||
try {
|
||||
await service.set('moonshot', { type: 'kimi' });
|
||||
await config.replace(DEFAULT_PROVIDER_SECTION, 'moonshot');
|
||||
expect(config.get<string>(DEFAULT_PROVIDER_SECTION)).toBe('moonshot');
|
||||
it('supports CRUD and diffs state changes into onDidChangeProviders', async () => {
|
||||
const service = createService();
|
||||
const events: Array<{
|
||||
added: readonly string[];
|
||||
removed: readonly string[];
|
||||
changed: readonly string[];
|
||||
}> = [];
|
||||
service.onDidChangeProviders((e) =>
|
||||
events.push({ added: e.added, removed: e.removed, changed: e.changed }),
|
||||
);
|
||||
|
||||
await service.delete('moonshot');
|
||||
expect(config.get<string>(DEFAULT_PROVIDER_SECTION)).toBeUndefined();
|
||||
} finally {
|
||||
host.dispose();
|
||||
}
|
||||
const moonshot: ProviderConfig = { type: 'kimi', baseUrl: 'https://api.moonshot.ai/v1' };
|
||||
await service.set('moonshot', moonshot);
|
||||
expect(service.get('moonshot')).toEqual(moonshot);
|
||||
expect(service.list()).toEqual({ moonshot });
|
||||
expect(events).toEqual([{ added: ['moonshot'], removed: [], changed: [] }]);
|
||||
|
||||
const updated: ProviderConfig = { ...moonshot, apiKey: 'sk-1' };
|
||||
await service.set('moonshot', updated);
|
||||
expect(events.at(-1)).toEqual({ added: [], removed: [], changed: ['moonshot'] });
|
||||
|
||||
// Rewriting with an identical record is silent — no event fires.
|
||||
await service.set('moonshot', updated);
|
||||
expect(events).toHaveLength(2);
|
||||
|
||||
await service.delete('moonshot');
|
||||
expect(service.get('moonshot')).toBeUndefined();
|
||||
expect(events.at(-1)).toEqual({ added: [], removed: ['moonshot'], changed: [] });
|
||||
});
|
||||
|
||||
it('loadAll fires only for real diffs on re-sync', async () => {
|
||||
const service = createService({ moonshot: { type: 'kimi' } });
|
||||
const events: unknown[] = [];
|
||||
service.onDidChangeProviders((e) =>
|
||||
events.push({ added: e.added, removed: e.removed, changed: e.changed }),
|
||||
);
|
||||
|
||||
service.loadAll({ moonshot: { type: 'kimi' } }, undefined);
|
||||
expect(events).toHaveLength(0);
|
||||
|
||||
service.loadAll({ moonshot: { type: 'kimi' }, other: { baseUrl: 'https://example.com' } }, undefined);
|
||||
expect(events).toEqual([{ added: ['other'], removed: [], changed: [] }]);
|
||||
});
|
||||
|
||||
it('replaceAll replaces the records and keeps the default pointer', async () => {
|
||||
const service = createService({ a: { type: 'kimi' }, b: { type: 'kimi' } });
|
||||
await service.setDefaultProvider('a');
|
||||
|
||||
await service.replaceAll({ c: { type: 'kimi' } });
|
||||
expect(service.list()).toEqual({ c: { type: 'kimi' } });
|
||||
expect(service.getDefaultProvider()).toBe('a');
|
||||
});
|
||||
|
||||
it('clears the defaultProvider pointer when the default provider is deleted', async () => {
|
||||
const service = createService({ moonshot: { type: 'kimi' } });
|
||||
const pointerEvents: Array<string | undefined> = [];
|
||||
service.onDidChangeDefaultProvider((e) => pointerEvents.push(e.id));
|
||||
|
||||
await service.setDefaultProvider('moonshot');
|
||||
expect(service.getDefaultProvider()).toBe('moonshot');
|
||||
|
||||
await service.delete('moonshot');
|
||||
expect(service.getDefaultProvider()).toBeUndefined();
|
||||
expect(pointerEvents).toEqual(['moonshot', undefined]);
|
||||
});
|
||||
|
||||
it('a mutation resolves only after the listeners’ waitUntil work completes', async () => {
|
||||
const service = createService();
|
||||
let persistDone = false;
|
||||
service.onDidChangeProviders((e) => {
|
||||
e.waitUntil(
|
||||
new Promise<void>((resolve) => setTimeout(resolve, 50)).then(() => {
|
||||
persistDone = true;
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
await service.set('moonshot', { type: 'kimi' });
|
||||
expect(persistDone).toBe(true);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import {
|
|||
IConfigService,
|
||||
type ResolvedConfig,
|
||||
} from '#/app/config/config';
|
||||
import type { IModelOAuthTokens } from '#/kosong/model/modelOAuth';
|
||||
|
||||
export class StubConfigService implements IConfigService {
|
||||
declare readonly _serviceBrand: undefined;
|
||||
|
|
@ -123,3 +124,24 @@ export function stubOAuthService(tokenProvider?: StubTokenProvider): IOAuthServi
|
|||
getCachedAccessToken: () => Promise.resolve(undefined),
|
||||
} as unknown as IOAuthService;
|
||||
}
|
||||
|
||||
/**
|
||||
* The kosong-side OAuth port stub (`IModelOAuthTokens`), mirroring what the
|
||||
* real `app/kosongConfig` adapter does over `IOAuthService`: a programmable
|
||||
* token provider for `getAccessToken` and a probeable cached-token flag.
|
||||
*/
|
||||
export function stubModelOAuthTokens(
|
||||
tokenProvider?: StubTokenProvider,
|
||||
cachedToken?: string,
|
||||
): IModelOAuthTokens {
|
||||
return {
|
||||
_serviceBrand: undefined,
|
||||
hasCachedAccessToken: () => Promise.resolve(cachedToken !== undefined),
|
||||
getAccessToken: (_provider, _oauthRef, options) =>
|
||||
tokenProvider === undefined
|
||||
? Promise.reject(new Error('auth.login_required'))
|
||||
: tokenProvider.getAccessToken(
|
||||
options?.force === true ? { force: true } : undefined,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ import {
|
|||
type Scope,
|
||||
} from '@moonshot-ai/agent-core-v2';
|
||||
import { setDefaultModelResponseSchema } from '@moonshot-ai/agent-core-v2/kosong/model/catalog';
|
||||
import { refreshProviderModelsResponseSchema } from '@moonshot-ai/agent-core-v2/kosong/model/discovery';
|
||||
import { refreshProviderModelsResponseSchema } from '@moonshot-ai/agent-core-v2/app/kosongConfig/discovery';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { errEnvelope, okEnvelope } from '../envelope';
|
||||
|
|
|
|||
266
packages/klient/examples/kosong-config-stress.ts
Normal file
266
packages/klient/examples/kosong-config-stress.ts
Normal file
|
|
@ -0,0 +1,266 @@
|
|||
/**
|
||||
* Stress check for the kosong ⇄ kosongConfig read/write path, against an
|
||||
* in-process engine (memory transport) bootstrapped on a throwaway home.
|
||||
*
|
||||
* The contract under pressure: an awaited kosong mutation resolves only after
|
||||
* the write has been persisted by the kosongConfig bridge (with retries on
|
||||
* transient disk failures), and config-originated writes land in the kosong
|
||||
* registries synchronously. Every phase asserts read-after-write visibility
|
||||
* through BOTH facades — `kosong.*` (registry view) and `config.get` (the
|
||||
* persisted section the bridge writes) — with no sleeps or flush helpers:
|
||||
* any settling gap shows up here as a failed assertion.
|
||||
*
|
||||
* Phases:
|
||||
* 1. sequential provider add/remove read-after-write;
|
||||
* 2. same-name add/remove flip-flop (hammering the persist chain with
|
||||
* alternating real writes and no-op merges);
|
||||
* 3. sequential default-model churn;
|
||||
* 4. concurrent burst adds (persist-chain serialization + merge);
|
||||
* 5. concurrent mixed sections: removes racing adds racing a default flip;
|
||||
* 6. config → kosong direction (config.replace must be visible to the
|
||||
* registry facade immediately);
|
||||
* 7. restart durability: dispose the app, re-bootstrap on the same home,
|
||||
* and compare every section against the pre-restart snapshot.
|
||||
*
|
||||
* pnpm -C packages/klient stress:kosong-config
|
||||
*
|
||||
* Env: KIMI_MODEL_NAME is unset for the run (it would pin `defaultModel` and
|
||||
* break the pointer assertions); restored on exit.
|
||||
*/
|
||||
import { mkdtemp, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import { bootstrap, logSeed, resolveLoggingConfig } from '@moonshot-ai/agent-core-v2';
|
||||
import { IConfigService } from '@moonshot-ai/agent-core-v2/app/config/config';
|
||||
import { IKosongConfigService } from '@moonshot-ai/agent-core-v2/app/kosongConfig/kosongConfig';
|
||||
import { type Klient } from '@moonshot-ai/klient';
|
||||
import { createKlient } from '@moonshot-ai/klient/memory';
|
||||
|
||||
function assert(cond: boolean, message: string): asserts cond {
|
||||
if (!cond) throw new Error(`assertion failed: ${message}`);
|
||||
}
|
||||
|
||||
interface ProvidersSectionView {
|
||||
readonly [name: string]: { readonly apiKey?: string } | undefined;
|
||||
}
|
||||
|
||||
interface ModelsSectionView {
|
||||
readonly [id: string]: { readonly provider?: string; readonly model?: string } | undefined;
|
||||
}
|
||||
|
||||
const apiKeyProvider = (apiKey: string) => ({
|
||||
type: 'openai',
|
||||
auth: { method: 'api-key' as const, apiKey },
|
||||
});
|
||||
|
||||
const anonymousModel = (id: string) => ({
|
||||
id,
|
||||
model: `${id}-model`,
|
||||
protocol: 'openai',
|
||||
baseUrl: 'http://127.0.0.1:1',
|
||||
auth: { method: 'api-key' as const, apiKey: `sk-${id}` },
|
||||
maxContextSize: 8192,
|
||||
});
|
||||
|
||||
/** Key-order-independent deep equality (post-restart TOML round-trips reorder keys). */
|
||||
const stable = (value: unknown): string =>
|
||||
JSON.stringify(value, (_key, v: unknown) =>
|
||||
v !== null && typeof v === 'object' && !Array.isArray(v)
|
||||
? Object.fromEntries(
|
||||
Object.entries(v as Record<string, unknown>).toSorted(([a], [b]) => a.localeCompare(b)),
|
||||
)
|
||||
: v,
|
||||
);
|
||||
|
||||
async function phase(label: string, ops: number, run: () => Promise<void>): Promise<void> {
|
||||
const startedAt = Date.now();
|
||||
await run();
|
||||
const elapsed = Date.now() - startedAt;
|
||||
console.log(
|
||||
`[ok] ${label} ${String(ops)} ops in ${String(elapsed)}ms (${((ops * 1000) / Math.max(elapsed, 1)).toFixed(0)} ops/s)`,
|
||||
);
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const homeDir = await mkdtemp(join(tmpdir(), 'klient-kosong-stress-'));
|
||||
const { app } = bootstrap({ homeDir }, [
|
||||
...logSeed(resolveLoggingConfig({ homeDir, env: process.env })),
|
||||
]);
|
||||
// Filled in right before the restart phase.
|
||||
let snapshot: Record<string, unknown> = {};
|
||||
try {
|
||||
const klient = createKlient({ scope: app });
|
||||
const config = klient.global.config;
|
||||
const kosong = klient.global.kosong;
|
||||
|
||||
// 1) Sequential read-after-write: each awaited add must already be in the
|
||||
// persisted section when it resolves — no settling window allowed.
|
||||
await phase('sequential provider add read-after-write', 30, async () => {
|
||||
for (let i = 0; i < 30; i += 1) {
|
||||
const name = `seq_${String(i)}`;
|
||||
await kosong.addProvider(name, apiKeyProvider(`sk-seq-${String(i)}`));
|
||||
const providers = await config.get<ProvidersSectionView>('providers');
|
||||
assert(
|
||||
providers[name]?.apiKey === `sk-seq-${String(i)}`,
|
||||
`providers.${name} persisted before addProvider resolved`,
|
||||
);
|
||||
const got = await kosong.getProvider(name);
|
||||
assert(got !== undefined, `providers.${name} visible to the registry facade`);
|
||||
}
|
||||
});
|
||||
|
||||
// 2) Same-name flip-flop: add → remove → add on one key, asserting both
|
||||
// directions of the write land before the await returns.
|
||||
await phase('same-name add/remove flip-flop', 30, async () => {
|
||||
for (let i = 0; i < 15; i += 1) {
|
||||
await kosong.addProvider('flip', apiKeyProvider(`sk-flip-${String(i)}`));
|
||||
let providers = await config.get<ProvidersSectionView>('providers');
|
||||
assert(providers['flip']?.apiKey === `sk-flip-${String(i)}`, 'flip add persisted');
|
||||
await kosong.removeProvider('flip');
|
||||
providers = await config.get<ProvidersSectionView>('providers');
|
||||
assert(providers['flip'] === undefined, 'flip remove persisted');
|
||||
}
|
||||
});
|
||||
|
||||
// 3) Sequential default-model churn: every awaited pointer write must be
|
||||
// visible in the persisted defaultModel section immediately.
|
||||
await phase('default-model churn', 22, async () => {
|
||||
await kosong.addProvider(anonymousModel('churn_a'));
|
||||
await kosong.addProvider(anonymousModel('churn_b'));
|
||||
for (let i = 0; i < 20; i += 1) {
|
||||
const id = i % 2 === 0 ? 'churn_a' : 'churn_b';
|
||||
await kosong.setDefaultModel(id);
|
||||
const def = await config.get<string>('defaultModel');
|
||||
assert(def === id, `defaultModel=${id} persisted before setDefaultModel resolved`);
|
||||
}
|
||||
});
|
||||
|
||||
// 4) Concurrent burst: 40 adds racing on one section. The persist chain
|
||||
// must serialize them and converge to the full set — no lost updates.
|
||||
await phase('concurrent burst adds', 40, async () => {
|
||||
await Promise.all(
|
||||
Array.from({ length: 40 }, (_, i) =>
|
||||
kosong.addProvider(`burst_${String(i)}`, apiKeyProvider(`sk-burst-${String(i)}`)),
|
||||
),
|
||||
);
|
||||
const providers = await config.get<ProvidersSectionView>('providers');
|
||||
for (let i = 0; i < 40; i += 1) {
|
||||
assert(
|
||||
providers[`burst_${String(i)}`]?.apiKey === `sk-burst-${String(i)}`,
|
||||
`burst_${String(i)} survived the concurrent burst`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// 5) Mixed sections racing: removes of phase-1 providers + anonymous-model
|
||||
// adds (models section) + a default flip, all in flight at once.
|
||||
await phase('concurrent mixed sections', 41, async () => {
|
||||
await Promise.all([
|
||||
...Array.from({ length: 30 }, (_, i) => kosong.removeProvider(`seq_${String(i)}`)),
|
||||
...Array.from({ length: 10 }, (_, i) => kosong.addProvider(anonymousModel(`mix_${String(i)}`))),
|
||||
kosong.setDefaultModel('churn_b'),
|
||||
]);
|
||||
const providers = await config.get<ProvidersSectionView>('providers');
|
||||
const models = await config.get<ModelsSectionView>('models');
|
||||
for (let i = 0; i < 30; i += 1) {
|
||||
assert(providers[`seq_${String(i)}`] === undefined, `seq_${String(i)} removal persisted`);
|
||||
}
|
||||
for (let i = 0; i < 10; i += 1) {
|
||||
assert(models[`mix_${String(i)}`]?.model === `mix_${String(i)}-model`, `mix_${String(i)} persisted`);
|
||||
}
|
||||
assert((await config.get<string>('defaultModel')) === 'churn_b', 'default flip persisted');
|
||||
});
|
||||
|
||||
// 6) config → kosong: a config-originated write must be visible to the
|
||||
// registry facade as soon as its await resolves.
|
||||
await phase('config-originated replace visible to registry', 4, async () => {
|
||||
await config.replace({
|
||||
domain: 'providers',
|
||||
value: {
|
||||
...(await config.get<ProvidersSectionView>('providers')),
|
||||
cfg_only: { type: 'openai', apiKey: 'sk-cfg-only' },
|
||||
},
|
||||
});
|
||||
const got = await kosong.getProvider('cfg_only');
|
||||
assert(got !== undefined, 'config.replace(providers) visible to kosong.getProvider');
|
||||
|
||||
await config.replace({
|
||||
domain: 'models',
|
||||
value: {
|
||||
...(await config.get<ModelsSectionView>('models')),
|
||||
cfg_model: { provider: 'cfg_only', model: 'cfg-model', maxContextSize: 4096 },
|
||||
},
|
||||
});
|
||||
const modelIds = (await kosong.listModels()).map((m) => m.model);
|
||||
assert(modelIds.includes('cfg_model'), 'config.replace(models) visible to kosong.listModels');
|
||||
|
||||
await config.set({ domain: 'providers', patch: { cfg_only: { apiKey: 'sk-cfg-updated' } } });
|
||||
const providers = await config.get<ProvidersSectionView>('providers');
|
||||
assert(providers['cfg_only']?.apiKey === 'sk-cfg-updated', 'config.set merge persisted');
|
||||
|
||||
await kosong.removeProvider('cfg_only');
|
||||
assert(
|
||||
(await config.get<ProvidersSectionView>('providers'))['cfg_only'] === undefined,
|
||||
'registry remove persisted over the config-originated entry',
|
||||
);
|
||||
});
|
||||
|
||||
snapshot = {
|
||||
providers: await config.get('providers'),
|
||||
models: await config.get('models'),
|
||||
defaultProvider: await config.get('defaultProvider'),
|
||||
defaultModel: await config.get('defaultModel'),
|
||||
};
|
||||
|
||||
await klient.close();
|
||||
} finally {
|
||||
app.dispose();
|
||||
}
|
||||
|
||||
// 7) Restart durability: a fresh engine on the SAME home must rehydrate the
|
||||
// exact pre-restart state — the ultimate proof the writes hit the disk.
|
||||
await phase('restart durability', 1, async () => {
|
||||
const { app: app2 } = bootstrap({ homeDir }, [
|
||||
...logSeed(resolveLoggingConfig({ homeDir, env: process.env })),
|
||||
]);
|
||||
try {
|
||||
// Reads race the async startup otherwise: config loads from disk, then
|
||||
// the bridge hydrates the registries from it.
|
||||
await app2.accessor.get(IConfigService).ready;
|
||||
await app2.accessor.get(IKosongConfigService).ready;
|
||||
const klient2: Klient = createKlient({ scope: app2 });
|
||||
for (const [section, expected] of Object.entries(snapshot)) {
|
||||
const actual: unknown = await klient2.global.config.get(section);
|
||||
if (stable(actual) !== stable(expected)) {
|
||||
console.error(`[diff] ${section}\n expected: ${stable(expected)}\n actual: ${stable(actual)}`);
|
||||
}
|
||||
assert(
|
||||
stable(actual) === stable(expected),
|
||||
`${section} rehydrated from disk equal to the pre-restart snapshot`,
|
||||
);
|
||||
}
|
||||
const providerIds = new Set((await klient2.global.kosong.listProviders()).map((p) => p.id));
|
||||
for (const name of Object.keys(snapshot['providers'] as Record<string, unknown>)) {
|
||||
assert(providerIds.has(name), `provider ${name} listed after restart`);
|
||||
}
|
||||
await klient2.close();
|
||||
} finally {
|
||||
app2.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
await rm(homeDir, { recursive: true, force: true });
|
||||
console.log('kosong-config stress: OK');
|
||||
}
|
||||
|
||||
const pinnedModelEnv = process.env['KIMI_MODEL_NAME'];
|
||||
delete process.env['KIMI_MODEL_NAME'];
|
||||
try {
|
||||
await main();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
} finally {
|
||||
if (pinnedModelEnv !== undefined) process.env['KIMI_MODEL_NAME'] = pinnedModelEnv;
|
||||
}
|
||||
|
|
@ -38,6 +38,7 @@
|
|||
"smoke": "tsx --tsconfig ./tsconfig.examples.json --import ../../build/register-raw-text-loader.mjs examples/smoke.ts",
|
||||
"smoke:boundary": "tsx --tsconfig ./tsconfig.examples.json --import ../../build/register-raw-text-loader.mjs examples/model-requester-boundary.ts",
|
||||
"smoke:select-tools": "tsx --tsconfig ./tsconfig.examples.json --import ../../build/register-raw-text-loader.mjs examples/kimi-select-tools.ts",
|
||||
"stress:kosong-config": "tsx --tsconfig ./tsconfig.examples.json --import ../../build/register-raw-text-loader.mjs examples/kosong-config-stress.ts",
|
||||
"clean": "rm -rf dist",
|
||||
"docker:e2e": "bash scripts/run-docker-e2e.sh"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
/**
|
||||
* `providerDiscovery` — the engine's `IProviderDiscoveryService`: remote
|
||||
* provider-model discovery and config sync. Mirrors
|
||||
* `agent-core-v2/kosong/model/discovery.ts`.
|
||||
* `agent-core-v2/app/kosongConfig/discovery.ts`.
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ import type {
|
|||
} from '@moonshot-ai/agent-core-v2/app/hostFolderBrowser/hostFolderBrowser';
|
||||
import type { ModelRecord } from '@moonshot-ai/agent-core-v2/kosong/model/model';
|
||||
import type { IModelCatalog } from '@moonshot-ai/agent-core-v2/kosong/model/catalog';
|
||||
import type { IProviderDiscoveryService } from '@moonshot-ai/agent-core-v2/kosong/model/discovery';
|
||||
import type { IProviderDiscoveryService } from '@moonshot-ai/agent-core-v2/app/kosongConfig/discovery';
|
||||
|
||||
import type { AnonymousProviderInput, GenerateEvent, GenerateInput, GenerateParams, ProviderInput } from './kosong-types.js';
|
||||
import type {
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import { IWorkspaceService } from '@moonshot-ai/agent-core-v2/app/workspace/work
|
|||
import { IConfigService } from '@moonshot-ai/agent-core-v2/app/config/config';
|
||||
import { IModelService } from '@moonshot-ai/agent-core-v2/kosong/model/model';
|
||||
import { IModelCatalog } from '@moonshot-ai/agent-core-v2/kosong/model/catalog';
|
||||
import { IProviderDiscoveryService } from '@moonshot-ai/agent-core-v2/kosong/model/discovery';
|
||||
import { IProviderDiscoveryService } from '@moonshot-ai/agent-core-v2/app/kosongConfig/discovery';
|
||||
import { IProviderService } from '@moonshot-ai/agent-core-v2/kosong/provider/provider';
|
||||
import {
|
||||
IAuthSummaryService,
|
||||
|
|
|
|||
|
|
@ -84,7 +84,7 @@ import type {
|
|||
} from '@moonshot-ai/agent-core-v2/app/hostFolderBrowser/hostFolderBrowser';
|
||||
import type { ModelRecord } from '@moonshot-ai/agent-core-v2/kosong/model/model';
|
||||
import type { IModelCatalog } from '@moonshot-ai/agent-core-v2/kosong/model/catalog';
|
||||
import type { IProviderDiscoveryService } from '@moonshot-ai/agent-core-v2/kosong/model/discovery';
|
||||
import type { IProviderDiscoveryService } from '@moonshot-ai/agent-core-v2/app/kosongConfig/discovery';
|
||||
import type {
|
||||
GetPluginInfoInput,
|
||||
InstallPluginInput,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue