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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 16 additions & 8 deletions harness/src/run-turn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import {
SettingsManager,
type FileEntry,
} from "@earendil-works/pi-coding-agent";
import { getModel, getModels, getProviders, type AssistantMessage } from "@earendil-works/pi-ai";
import { getModel, getModels, getProviders, type AssistantMessage, type Model } from "@earendil-works/pi-ai";
import { RedisSessionBackend } from "@sh/session-backend";
import { BufferedRedisBackend } from "./buffered-redis-backend.js";
import { flushExtension } from "./flush-extension.js";
Expand Down Expand Up @@ -52,7 +52,7 @@ export function resolveModelSelection(
* cost/context numbers are placeholders (self-hosted, not billed); contextWindow/maxTokens are
* conservative defaults — override via SH_MODEL_CONTEXT_WINDOW / SH_MODEL_MAX_TOKENS if needed.
*/
function synthesizeCustomModel(modelId: string, env: NodeJS.ProcessEnv) {
function synthesizeCustomModel(modelId: string, env: NodeJS.ProcessEnv): Model<"anthropic-messages"> {
const baseUrl = env.ANTHROPIC_BASE_URL;
if (!baseUrl) {
throw new Error(
Expand All @@ -61,7 +61,10 @@ function synthesizeCustomModel(modelId: string, env: NodeJS.ProcessEnv) {
}
const contextWindow = Number(env.SH_MODEL_CONTEXT_WINDOW) || 131072;
const maxTokens = Number(env.SH_MODEL_MAX_TOKENS) || 8192;
return {
// Typed as Model<"anthropic-messages"> (not `as ReturnType<typeof getModel>`) so tsc checks
// the shape — if pi-ai's Model type gains a required field, this fails to compile instead of
// silently omitting it.
const model: Model<"anthropic-messages"> = {
id: modelId,
name: modelId,
api: "anthropic-messages",
Expand All @@ -71,14 +74,15 @@ function synthesizeCustomModel(modelId: string, env: NodeJS.ProcessEnv) {
// "custom" has no env-key mapping and fails with `No API key found for "custom"`. Request
// routing is by baseUrl + api, not provider, so tagging it "anthropic" sends traffic to the
// custom baseUrl while satisfying the key lookup. Overridable via SH_MODEL_PROVIDER.
provider: (env.SH_MODEL_PROVIDER ?? "anthropic") as never,
provider: (env.SH_MODEL_PROVIDER ?? "anthropic") as Model<"anthropic-messages">["provider"],
baseUrl,
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow,
maxTokens,
} as ReturnType<typeof getModel> & object;
};
return model;
}

/**
Expand All @@ -91,9 +95,13 @@ function synthesizeCustomModel(modelId: string, env: NodeJS.ProcessEnv) {
* model from SH_MODEL + ANTHROPIC_BASE_URL — for self-hosted endpoints (vLLM/llm-d) whose
* served model id is not a known Anthropic id. See synthesizeCustomModel().
*/
export function requireModel(provider: string, modelId: string) {
if (process.env.SH_MODEL_CUSTOM === "1") {
return synthesizeCustomModel(modelId, process.env);
export function requireModel(
provider: string,
modelId: string,
env: NodeJS.ProcessEnv = process.env,
) {
if (env.SH_MODEL_CUSTOM === "1") {
return synthesizeCustomModel(modelId, env);
}
const model = getModel(provider as never, modelId as never);
if (model) return model;
Expand Down
45 changes: 45 additions & 0 deletions harness/test/run-turn-model.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,3 +51,48 @@ describe("requireModel", () => {
);
});
});

describe("requireModel with SH_MODEL_CUSTOM (self-hosted endpoint)", () => {
it("requires ANTHROPIC_BASE_URL when SH_MODEL_CUSTOM=1", () => {
expect(() =>
requireModel("anthropic", "meta-llama/Llama-3.3-70B-Instruct", { SH_MODEL_CUSTOM: "1" }),
).toThrowError(/SH_MODEL_CUSTOM=1 requires ANTHROPIC_BASE_URL/);
});

it("synthesizes an anthropic-messages model from SH_MODEL + ANTHROPIC_BASE_URL", () => {
const m = requireModel("anthropic", "meta-llama/Llama-3.3-70B-Instruct", {
SH_MODEL_CUSTOM: "1",
ANTHROPIC_BASE_URL: "http://vllm.my-ns.svc:8000",
}) as {
id: string;
name: string;
api: string;
provider: string;
baseUrl: string;
contextWindow: number;
maxTokens: number;
};
expect(m.id).toBe("meta-llama/Llama-3.3-70B-Instruct");
expect(m.name).toBe("meta-llama/Llama-3.3-70B-Instruct");
expect(m.api).toBe("anthropic-messages");
expect(m.baseUrl).toBe("http://vllm.my-ns.svc:8000");
// provider defaults to "anthropic" so pi's key lookup resolves ANTHROPIC_API_KEY.
expect(m.provider).toBe("anthropic");
// Conservative defaults when overrides are unset.
expect(m.contextWindow).toBe(131072);
expect(m.maxTokens).toBe(8192);
});

it("honors SH_MODEL_PROVIDER / SH_MODEL_CONTEXT_WINDOW / SH_MODEL_MAX_TOKENS overrides", () => {
const m = requireModel("anthropic", "some/model", {
SH_MODEL_CUSTOM: "1",
ANTHROPIC_BASE_URL: "http://endpoint:8000",
SH_MODEL_PROVIDER: "vllm",
SH_MODEL_CONTEXT_WINDOW: "65536",
SH_MODEL_MAX_TOKENS: "4096",
}) as { provider: string; contextWindow: number; maxTokens: number };
expect(m.provider).toBe("vllm");
expect(m.contextWindow).toBe(65536);
expect(m.maxTokens).toBe(4096);
});
});
Loading