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

* 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:
Haozhe 2026-07-23 00:32:10 +08:00 committed by GitHub
parent e0f2a41769
commit 188c0fcbf7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
56 changed files with 2590 additions and 1115 deletions

View file

@ -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 |

View 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.

View file

@ -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` (L1L2) 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;

View 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;

View file

@ -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,

View file

@ -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';

View file

@ -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();

View file

@ -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(),

View file

@ -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) {

View file

@ -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 };
}

View file

@ -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;

View 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);

View file

@ -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`).
*/

View file

@ -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',
);

View file

@ -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'));

View 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');

View file

@ -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',
);

View file

@ -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',
);

View file

@ -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.

View file

@ -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);

View file

@ -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>;

View file

@ -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 };
},
};

View file

@ -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,
});

View file

@ -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);

View file

@ -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

View file

@ -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.`,
);
}

View 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');

View file

@ -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);
}
}

View file

@ -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

View file

@ -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,
});

View file

@ -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> =

View file

@ -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(

View 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;
}

View file

@ -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);
}),
};
}

View file

@ -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),

View file

@ -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) =>

View file

@ -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);

View file

@ -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,

View file

@ -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();
}

View file

@ -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>;

View file

@ -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();
}
});
});

View file

@ -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();
});
});

View file

@ -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', () => {

View file

@ -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 () => {},
};
}

View file

@ -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

View file

@ -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();
}

View file

@ -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]);
});
});

View file

@ -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);
});
});

View file

@ -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,
),
};
}

View file

@ -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';

View 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;
}

View file

@ -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"
},

View file

@ -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';

View file

@ -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 {

View file

@ -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,

View file

@ -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,