Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ Each converter declares its stream format via `getStreamFormat()`.
Translates model-specific dialect differences (context windows, thinking→reasoning_effort, vision rules).
- **Interface**: `adapters/model-translator.ts`
- **Implementations**: GLMAdapter, GrokAdapter, MiniMaxAdapter, DeepSeekAdapter, QwenAdapter, CodexAdapter
- **Selection**: `AdapterManager` auto-selects based on model ID
- **Selection**: `resolveModelDialect(modelId)` selects based on model ID

### Layer 3: ProviderTransport — HTTP transport
Handles auth, endpoints, headers, rate limiting. Optionally overrides stream format for aggregators.
Expand All @@ -113,7 +113,7 @@ transport.overrideStreamFormat() ?? modelAdapter.getStreamFormat() ?? providerAd
```

**Adding a new provider**: Add one entry to `PROVIDER_PROFILES` table in `providers/provider-profiles.ts`.
**Adding a new model**: Create a ModelTranslator adapter, register in `adapters/adapter-manager.ts`.
**Adding a new model**: Create a ModelDialect adapter, add it to the `DIALECTS` array in `adapters/dialect-manager.ts`.
**Verifying wiring**: `claudish --probe <model>` shows the full adapter composition.

### Stream Parsers
Expand Down
65 changes: 19 additions & 46 deletions packages/cli/src/adapters/dialect-manager.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,8 @@
/**
* DialectManagerselects the appropriate Layer 2 ModelDialect for a given model.
* resolveModelDialectselect the appropriate ModelDialect for a given model.
*
* This allows ComposedHandler to apply model-specific quirks independent of
* which Layer 1 APIFormat or Layer 3 ProviderTransport are used:
* - Grok: XML function calls
* - Gemini: Thought signatures in reasoning_details
* - DeepSeek, GLM, etc.: thinking param stripping / mapping
* Pure function. Returns the first dialect whose shouldHandle() matches,
* or DefaultAPIFormat as fallback.
*/

import { BaseAPIFormat, DefaultAPIFormat } from "./base-api-format.js";
Expand All @@ -19,46 +16,22 @@ import { DeepSeekModelDialect } from "./deepseek-model-dialect.js";
import { GLMModelDialect } from "./glm-model-dialect.js";
import { XiaomiModelDialect } from "./xiaomi-model-dialect.js";

export class DialectManager {
private adapters: BaseAPIFormat[];
private defaultAdapter: DefaultAPIFormat;
const DIALECTS: Array<new (modelId: string) => BaseAPIFormat> = [
GrokModelDialect,
GeminiAPIFormat,
CodexAPIFormat, // Must precede OpenAIAPIFormat
OpenAIAPIFormat,
QwenModelDialect,
MiniMaxModelDialect,
DeepSeekModelDialect,
GLMModelDialect,
XiaomiModelDialect,
];

constructor(modelId: string) {
// Register all available dialects/formats
this.adapters = [
new GrokModelDialect(modelId),
new GeminiAPIFormat(modelId),
new CodexAPIFormat(modelId), // Must be before OpenAIAPIFormat (codex matches first)
new OpenAIAPIFormat(modelId),
new QwenModelDialect(modelId),
new MiniMaxModelDialect(modelId),
new DeepSeekModelDialect(modelId),
new GLMModelDialect(modelId),
new XiaomiModelDialect(modelId),
];
this.defaultAdapter = new DefaultAPIFormat(modelId);
}

/**
* Get the appropriate dialect/format for the current model
*/
getAdapter(): BaseAPIFormat {
for (const adapter of this.adapters) {
if (adapter.shouldHandle(this.defaultAdapter["modelId"])) {
return adapter;
}
}
return this.defaultAdapter;
}

/**
* Check if current model needs special handling
*/
needsTransformation(): boolean {
return this.getAdapter() !== this.defaultAdapter;
export function resolveModelDialect(modelId: string): BaseAPIFormat {
for (const Dialect of DIALECTS) {
const d = new Dialect(modelId);
if (d.shouldHandle(modelId)) return d;
}
return new DefaultAPIFormat(modelId);
}

// Backward-compatible alias
/** @deprecated Use DialectManager */
export { DialectManager as AdapterManager };
12 changes: 2 additions & 10 deletions packages/cli/src/adapters/index.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,8 @@
/**
* Model format and dialect implementations
* Model format and dialect exports
*/

export { BaseAPIFormat, DefaultAPIFormat } from "./base-api-format.js";
export type { ToolCall, AdapterResult } from "./base-api-format.js";
export { GrokModelDialect } from "./grok-model-dialect.js";
export { DialectManager } from "./dialect-manager.js";

// Backward-compatible aliases
export {
BaseAPIFormat as BaseModelAdapter,
DefaultAPIFormat as DefaultAdapter,
} from "./base-api-format.js";
export { GrokModelDialect as GrokAdapter } from "./grok-model-dialect.js";
export { DialectManager as AdapterManager } from "./dialect-manager.js";
export { resolveModelDialect } from "./dialect-manager.js";
2 changes: 1 addition & 1 deletion packages/cli/src/adapters/litellm-api-format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ export class LiteLLMAPIFormat extends DefaultAPIFormat {
}

shouldHandle(modelId: string): boolean {
return false; // Always used explicitly, not via DialectManager matching
return false; // Always used explicitly, not via resolveModelDialect matching
}

supportsVision(): boolean {
Expand Down
5 changes: 2 additions & 3 deletions packages/cli/src/adapters/local-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
*/

import { BaseAPIFormat, type AdapterResult } from "./base-api-format.js";
import { DialectManager } from "./dialect-manager.js";
import { resolveModelDialect } from "./dialect-manager.js";
import { log } from "../logger.js";

interface SamplingParams {
Expand All @@ -31,8 +31,7 @@ export class LocalModelAdapter extends BaseAPIFormat {
super(modelId);
this.providerName = providerName;

const manager = new DialectManager(modelId);
this.innerAdapter = manager.getAdapter();
this.innerAdapter = resolveModelDialect(modelId);
}

// ─── Text processing delegates to inner adapter ───────────────────
Expand Down
5 changes: 2 additions & 3 deletions packages/cli/src/adapters/openrouter-api-format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
*/

import { BaseAPIFormat, type AdapterResult } from "./base-api-format.js";
import { DialectManager } from "./dialect-manager.js";
import { resolveModelDialect } from "./dialect-manager.js";
import { removeUriFormat } from "../transform.js";
import { log } from "../logger.js";

Expand All @@ -22,8 +22,7 @@ export class OpenRouterAPIFormat extends BaseAPIFormat {
super(modelId);

// Get model-specific dialect (GrokModelDialect, GeminiAPIFormat, etc.)
const manager = new DialectManager(modelId);
this.innerAdapter = manager.getAdapter();
this.innerAdapter = resolveModelDialect(modelId);
}

/** Synchronous reasoning support check via model ID patterns */
Expand Down
7 changes: 3 additions & 4 deletions packages/cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1430,10 +1430,9 @@ async function probeModelRouting(models: string[], jsonOutput: boolean): Promise
declaredStreamFormat = "openai-sse";
}

// Get model dialect via DialectManager
const { DialectManager } = await import("./adapters/dialect-manager.js");
const adapterManager = new DialectManager(modelName);
const modelTranslator = adapterManager.getAdapter();
// Get model dialect via resolveModelDialect
const { resolveModelDialect } = await import("./adapters/dialect-manager.js");
const modelTranslator = resolveModelDialect(modelName);
const modelTranslatorName = modelTranslator.getName();

// Transport overrides (aggregators that normalize responses to openai-sse)
Expand Down
42 changes: 17 additions & 25 deletions packages/cli/src/e2e-glm-adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,15 @@
*
* Validates:
* 1. GLMModelDialect model detection, context windows, and vision support
* 2. DialectManager correctly selects GLMModelDialect for GLM models
* 2. resolveModelDialect correctly selects GLMModelDialect for GLM models
* 3. ComposedHandler three-layer architecture — model dialect provides model-specific
* overrides (context window, vision, prepareRequest) even when a provider format
* (LiteLLMAPIFormat, OpenRouterAPIFormat) is set as the explicit adapter
*/

import { describe, test, expect } from "bun:test";
import { GLMModelDialect } from "./adapters/glm-model-dialect.js";
import { DialectManager } from "./adapters/dialect-manager.js";
import { resolveModelDialect } from "./adapters/dialect-manager.js";
import { LiteLLMAPIFormat } from "./adapters/litellm-api-format.js";
import { DefaultAPIFormat } from "./adapters/base-api-format.js";

Expand Down Expand Up @@ -134,50 +134,46 @@ describe("GLMModelDialect — processTextContent", () => {
});
});

// ─── Group 2: DialectManager selects GLMModelDialect ─────────────────────────
// ─── Group 2: resolveModelDialect selects GLMModelDialect ─────────────────────

describe("DialectManager — GLM routing", () => {
describe("resolveModelDialect — GLM routing", () => {
test("selects GLMModelDialect for glm-5", () => {
const manager = new DialectManager("glm-5");
const adapter = manager.getAdapter();
const adapter = resolveModelDialect("glm-5");

expect(adapter.getName()).toBe("GLMModelDialect");
});

test("selects GLMModelDialect for glm-4-long", () => {
const manager = new DialectManager("glm-4-long");
const adapter = manager.getAdapter();
const adapter = resolveModelDialect("glm-4-long");

expect(adapter.getName()).toBe("GLMModelDialect");
});

test("does NOT select GLMModelDialect for gpt-4o", () => {
const manager = new DialectManager("gpt-4o");
const adapter = manager.getAdapter();
const adapter = resolveModelDialect("gpt-4o");

expect(adapter.getName()).not.toBe("GLMModelDialect");
});

test("needsTransformation returns true for GLM models", () => {
const manager = new DialectManager("glm-5");
expect(manager.needsTransformation()).toBe(true);
const adapter = resolveModelDialect("glm-5");
expect(adapter.getName() !== "DefaultAPIFormat").toBe(true);
});
});

// ─── Group 3: Three-layer adapter architecture ───────────────────────────────
//
// When a format adapter (LiteLLMAPIFormat) is the explicit adapter, the model
// dialect (GLMModelDialect) should still be resolved by DialectManager for
// dialect (GLMModelDialect) should still be resolved by resolveModelDialect for
// model-specific concerns.

describe("Three-layer adapter — model dialect overrides format adapter", () => {
test("DialectManager resolves GLMModelDialect even when LiteLLMAPIFormat would be used", () => {
test("resolveModelDialect resolves GLMModelDialect even when LiteLLMAPIFormat would be used", () => {
// Simulate what ComposedHandler does:
// 1. Explicit adapter = LiteLLMAPIFormat (L1 wire format)
// 2. DialectManager.getAdapter() = GLMModelDialect (L2 model quirks)
// 2. resolveModelDialect() = GLMModelDialect (L2 model quirks)
const litellmAdapter = new LiteLLMAPIFormat("glm-5", "https://example.com");
const adapterManager = new DialectManager("glm-5");
const modelAdapter = adapterManager.getAdapter();
const modelAdapter = resolveModelDialect("glm-5");

// Format adapter handles wire format / transport
expect(litellmAdapter.getName()).toBe("LiteLLMAPIFormat");
Expand All @@ -196,33 +192,29 @@ describe("Three-layer adapter — model dialect overrides format adapter", () =>
});

test("model dialect provides correct context window for glm-4-long via LiteLLM", () => {
const adapterManager = new DialectManager("glm-4-long");
const modelAdapter = adapterManager.getAdapter();
const modelAdapter = resolveModelDialect("glm-4-long");

expect(modelAdapter.getName()).toBe("GLMModelDialect");
expect(modelAdapter.getContextWindow()).toBe(1_000_000);
});

test("model dialect correctly reports no vision for glm-4-flash via LiteLLM", () => {
const adapterManager = new DialectManager("glm-4-flash");
const modelAdapter = adapterManager.getAdapter();
const modelAdapter = resolveModelDialect("glm-4-flash");

expect(modelAdapter.getName()).toBe("GLMModelDialect");
expect(modelAdapter.supportsVision()).toBe(false);
});

test("non-GLM model via LiteLLM falls back to DefaultAPIFormat", () => {
const adapterManager = new DialectManager("some-unknown-model");
const modelAdapter = adapterManager.getAdapter();
const modelAdapter = resolveModelDialect("some-unknown-model");

// Should be DefaultAPIFormat, not GLMModelDialect
expect(modelAdapter.getName()).toBe("DefaultAPIFormat");
});

test("model dialect strips thinking, format adapter does not", () => {
const litellmAdapter = new LiteLLMAPIFormat("glm-5", "https://example.com");
const adapterManager = new DialectManager("glm-5");
const modelAdapter = adapterManager.getAdapter();
const modelAdapter = resolveModelDialect("glm-5");

// Format adapter does not strip thinking (no override)
const request1 = { model: "glm-5", thinking: { budget: 10000 }, messages: [] };
Expand Down
22 changes: 8 additions & 14 deletions packages/cli/src/e2e-model-catalog.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import { lookupModel } from "./adapters/model-catalog.js";
import { MiniMaxModelDialect } from "./adapters/minimax-model-dialect.js";
import { GLMModelDialect } from "./adapters/glm-model-dialect.js";
import { GrokModelDialect } from "./adapters/grok-model-dialect.js";
import { DialectManager } from "./adapters/dialect-manager.js";
import { resolveModelDialect } from "./adapters/dialect-manager.js";
import { AnthropicAPIFormat } from "./adapters/anthropic-api-format.js";

const MINIMAX_API_KEY =
Expand Down Expand Up @@ -181,40 +181,34 @@ describe("Group 2: GrokModelDialect — catalog integration", () => {
});
});

describe("Group 2: DialectManager — correct dialect selection", () => {
describe("Group 2: resolveModelDialect — correct dialect selection", () => {
test("selects MiniMaxModelDialect for MiniMax-M2.7", () => {
const manager = new DialectManager("MiniMax-M2.7");
const adapter = manager.getAdapter();
const adapter = resolveModelDialect("MiniMax-M2.7");
expect(adapter.getName()).toBe("MiniMaxModelDialect");
});

test("selects GLMModelDialect for glm-5", () => {
const manager = new DialectManager("glm-5");
const adapter = manager.getAdapter();
const adapter = resolveModelDialect("glm-5");
expect(adapter.getName()).toBe("GLMModelDialect");
});

test("selects GrokModelDialect for grok-4", () => {
const manager = new DialectManager("grok-4");
const adapter = manager.getAdapter();
const adapter = resolveModelDialect("grok-4");
expect(adapter.getName()).toBe("GrokModelDialect");
});

test("selects GrokModelDialect for x-ai/grok-4-fast", () => {
const manager = new DialectManager("x-ai/grok-4-fast");
const adapter = manager.getAdapter();
const adapter = resolveModelDialect("x-ai/grok-4-fast");
expect(adapter.getName()).toBe("GrokModelDialect");
});

test("selects MiniMaxModelDialect for minimax-m2.5", () => {
const manager = new DialectManager("minimax-m2.5");
const adapter = manager.getAdapter();
const adapter = resolveModelDialect("minimax-m2.5");
expect(adapter.getName()).toBe("MiniMaxModelDialect");
});

test("returns DefaultAPIFormat for unknown model", () => {
const manager = new DialectManager("totally-unknown-model-xyz");
const adapter = manager.getAdapter();
const adapter = resolveModelDialect("totally-unknown-model-xyz");
expect(adapter.getName()).toBe("DefaultAPIFormat");
});
});
Expand Down
22 changes: 11 additions & 11 deletions packages/cli/src/format-translation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -582,15 +582,15 @@ describe("Model Adapter Quirks", () => {
expect(request.thinking).toBeUndefined();
});

test("AdapterManager selects correct adapter for model IDs", async () => {
const { DialectManager } = await import("./adapters/dialect-manager.js");
test("resolveModelDialect selects correct adapter for model IDs", async () => {
const { resolveModelDialect } = await import("./adapters/dialect-manager.js");

expect(new DialectManager("glm-5").getAdapter().getName()).toBe("GLMModelDialect");
expect(new DialectManager("grok-3").getAdapter().getName()).toBe("GrokModelDialect");
expect(new DialectManager("minimax-m2.5").getAdapter().getName()).toBe("MiniMaxModelDialect");
expect(new DialectManager("qwen3.5-plus").getAdapter().getName()).toBe("QwenModelDialect");
expect(new DialectManager("deepseek-r1").getAdapter().getName()).toBe("DeepSeekModelDialect");
expect(new DialectManager("unknown-model").getAdapter().getName()).toBe("DefaultAPIFormat");
expect(resolveModelDialect("glm-5").getName()).toBe("GLMModelDialect");
expect(resolveModelDialect("grok-3").getName()).toBe("GrokModelDialect");
expect(resolveModelDialect("minimax-m2.5").getName()).toBe("MiniMaxModelDialect");
expect(resolveModelDialect("qwen3.5-plus").getName()).toBe("QwenModelDialect");
expect(resolveModelDialect("deepseek-r1").getName()).toBe("DeepSeekModelDialect");
expect(resolveModelDialect("unknown-model").getName()).toBe("DefaultAPIFormat");
});
});

Expand Down Expand Up @@ -656,9 +656,9 @@ describe("CodexAdapter", () => {
expect(new CodexAPIFormat("codex-mini").getName()).toBe("CodexAPIFormat");
});

test("AdapterManager selects CodexAPIFormat for codex-mini", async () => {
const { DialectManager } = await import("./adapters/dialect-manager.js");
expect(new DialectManager("codex-mini").getAdapter().getName()).toBe("CodexAPIFormat");
test("resolveModelDialect selects CodexAPIFormat for codex-mini", async () => {
const { resolveModelDialect } = await import("./adapters/dialect-manager.js");
expect(resolveModelDialect("codex-mini").getName()).toBe("CodexAPIFormat");
});
});

Expand Down
Loading