mirror of https://github.com/openclaw/openclaw.git
344 lines
9.8 KiB
TypeScript
344 lines
9.8 KiB
TypeScript
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
const hoisted = vi.hoisted(() => ({
|
|
resolveModelMock: vi.fn(),
|
|
getApiKeyForModelMock: vi.fn(),
|
|
applyLocalNoAuthHeaderOverrideMock: vi.fn(),
|
|
setRuntimeApiKeyMock: vi.fn(),
|
|
resolveCopilotApiTokenMock: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("./pi-embedded-runner/model.js", () => ({
|
|
resolveModel: hoisted.resolveModelMock,
|
|
}));
|
|
|
|
vi.mock("./model-auth.js", () => ({
|
|
getApiKeyForModel: hoisted.getApiKeyForModelMock,
|
|
applyLocalNoAuthHeaderOverride: hoisted.applyLocalNoAuthHeaderOverrideMock,
|
|
}));
|
|
|
|
vi.mock("./github-copilot-token.js", () => ({
|
|
resolveCopilotApiToken: hoisted.resolveCopilotApiTokenMock,
|
|
}));
|
|
|
|
let prepareSimpleCompletionModel: typeof import("./simple-completion-runtime.js").prepareSimpleCompletionModel;
|
|
|
|
beforeAll(async () => {
|
|
({ prepareSimpleCompletionModel } = await import("./simple-completion-runtime.js"));
|
|
});
|
|
|
|
beforeEach(() => {
|
|
hoisted.resolveModelMock.mockReset();
|
|
hoisted.getApiKeyForModelMock.mockReset();
|
|
hoisted.applyLocalNoAuthHeaderOverrideMock.mockReset();
|
|
hoisted.setRuntimeApiKeyMock.mockReset();
|
|
hoisted.resolveCopilotApiTokenMock.mockReset();
|
|
|
|
hoisted.applyLocalNoAuthHeaderOverrideMock.mockImplementation((model: unknown) => model);
|
|
|
|
hoisted.resolveModelMock.mockReturnValue({
|
|
model: {
|
|
provider: "anthropic",
|
|
id: "claude-opus-4-6",
|
|
},
|
|
authStorage: {
|
|
setRuntimeApiKey: hoisted.setRuntimeApiKeyMock,
|
|
},
|
|
modelRegistry: {},
|
|
});
|
|
hoisted.getApiKeyForModelMock.mockResolvedValue({
|
|
apiKey: "sk-test",
|
|
source: "env:TEST_API_KEY",
|
|
mode: "api-key",
|
|
});
|
|
hoisted.resolveCopilotApiTokenMock.mockResolvedValue({
|
|
token: "copilot-runtime-token",
|
|
expiresAt: Date.now() + 60_000,
|
|
source: "cache:/tmp/copilot-token.json",
|
|
baseUrl: "https://api.individual.githubcopilot.com",
|
|
});
|
|
});
|
|
|
|
describe("prepareSimpleCompletionModel", () => {
|
|
it("resolves model auth and sets runtime api key", async () => {
|
|
hoisted.getApiKeyForModelMock.mockResolvedValueOnce({
|
|
apiKey: " sk-test ",
|
|
source: "env:TEST_API_KEY",
|
|
mode: "api-key",
|
|
});
|
|
|
|
const result = await prepareSimpleCompletionModel({
|
|
cfg: undefined,
|
|
provider: "anthropic",
|
|
modelId: "claude-opus-4-6",
|
|
agentDir: "/tmp/openclaw-agent",
|
|
});
|
|
|
|
expect(result).toEqual(
|
|
expect.objectContaining({
|
|
model: expect.objectContaining({
|
|
provider: "anthropic",
|
|
id: "claude-opus-4-6",
|
|
}),
|
|
auth: expect.objectContaining({
|
|
mode: "api-key",
|
|
source: "env:TEST_API_KEY",
|
|
}),
|
|
}),
|
|
);
|
|
expect(hoisted.setRuntimeApiKeyMock).toHaveBeenCalledWith("anthropic", "sk-test");
|
|
});
|
|
|
|
it("returns error when model resolution fails", async () => {
|
|
hoisted.resolveModelMock.mockReturnValueOnce({
|
|
error: "Unknown model: anthropic/missing-model",
|
|
authStorage: {
|
|
setRuntimeApiKey: hoisted.setRuntimeApiKeyMock,
|
|
},
|
|
modelRegistry: {},
|
|
});
|
|
|
|
const result = await prepareSimpleCompletionModel({
|
|
cfg: undefined,
|
|
provider: "anthropic",
|
|
modelId: "missing-model",
|
|
});
|
|
|
|
expect(result).toEqual({
|
|
error: "Unknown model: anthropic/missing-model",
|
|
});
|
|
expect(hoisted.getApiKeyForModelMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("returns error when api key is missing and mode is not allowlisted", async () => {
|
|
hoisted.getApiKeyForModelMock.mockResolvedValueOnce({
|
|
source: "models.providers.anthropic",
|
|
mode: "api-key",
|
|
});
|
|
|
|
const result = await prepareSimpleCompletionModel({
|
|
cfg: undefined,
|
|
provider: "anthropic",
|
|
modelId: "claude-opus-4-6",
|
|
});
|
|
|
|
expect(result).toEqual({
|
|
error: 'No API key resolved for provider "anthropic" (auth mode: api-key).',
|
|
auth: {
|
|
source: "models.providers.anthropic",
|
|
mode: "api-key",
|
|
},
|
|
});
|
|
expect(hoisted.setRuntimeApiKeyMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("continues without api key when auth mode is allowlisted", async () => {
|
|
hoisted.resolveModelMock.mockReturnValueOnce({
|
|
model: {
|
|
provider: "amazon-bedrock",
|
|
id: "anthropic.claude-sonnet-4-6",
|
|
},
|
|
authStorage: {
|
|
setRuntimeApiKey: hoisted.setRuntimeApiKeyMock,
|
|
},
|
|
modelRegistry: {},
|
|
});
|
|
hoisted.getApiKeyForModelMock.mockResolvedValueOnce({
|
|
source: "aws-sdk default chain",
|
|
mode: "aws-sdk",
|
|
});
|
|
|
|
const result = await prepareSimpleCompletionModel({
|
|
cfg: undefined,
|
|
provider: "amazon-bedrock",
|
|
modelId: "anthropic.claude-sonnet-4-6",
|
|
allowMissingApiKeyModes: ["aws-sdk"],
|
|
});
|
|
|
|
expect(result).toEqual(
|
|
expect.objectContaining({
|
|
model: expect.objectContaining({
|
|
provider: "amazon-bedrock",
|
|
id: "anthropic.claude-sonnet-4-6",
|
|
}),
|
|
auth: {
|
|
source: "aws-sdk default chain",
|
|
mode: "aws-sdk",
|
|
},
|
|
}),
|
|
);
|
|
expect(hoisted.setRuntimeApiKeyMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("exchanges github token when provider is github-copilot", async () => {
|
|
hoisted.resolveModelMock.mockReturnValueOnce({
|
|
model: {
|
|
provider: "github-copilot",
|
|
id: "gpt-4.1",
|
|
},
|
|
authStorage: {
|
|
setRuntimeApiKey: hoisted.setRuntimeApiKeyMock,
|
|
},
|
|
modelRegistry: {},
|
|
});
|
|
hoisted.getApiKeyForModelMock.mockResolvedValueOnce({
|
|
apiKey: "ghu_test",
|
|
source: "profile:github-copilot:default",
|
|
mode: "token",
|
|
});
|
|
|
|
await prepareSimpleCompletionModel({
|
|
cfg: undefined,
|
|
provider: "github-copilot",
|
|
modelId: "gpt-4.1",
|
|
});
|
|
|
|
expect(hoisted.resolveCopilotApiTokenMock).toHaveBeenCalledWith({
|
|
githubToken: "ghu_test",
|
|
});
|
|
expect(hoisted.setRuntimeApiKeyMock).toHaveBeenCalledWith(
|
|
"github-copilot",
|
|
"copilot-runtime-token",
|
|
);
|
|
});
|
|
|
|
it("returns exchanged copilot token in auth.apiKey for github-copilot provider", async () => {
|
|
hoisted.resolveModelMock.mockReturnValueOnce({
|
|
model: {
|
|
provider: "github-copilot",
|
|
id: "gpt-4.1",
|
|
},
|
|
authStorage: {
|
|
setRuntimeApiKey: hoisted.setRuntimeApiKeyMock,
|
|
},
|
|
modelRegistry: {},
|
|
});
|
|
hoisted.getApiKeyForModelMock.mockResolvedValueOnce({
|
|
apiKey: "ghu_original_github_token",
|
|
source: "profile:github-copilot:default",
|
|
mode: "token",
|
|
});
|
|
|
|
const result = await prepareSimpleCompletionModel({
|
|
cfg: undefined,
|
|
provider: "github-copilot",
|
|
modelId: "gpt-4.1",
|
|
});
|
|
|
|
expect(result).not.toHaveProperty("error");
|
|
if ("error" in result) {
|
|
return;
|
|
}
|
|
|
|
// The returned auth.apiKey should be the exchanged runtime token,
|
|
// not the original GitHub token
|
|
expect(result.auth.apiKey).toBe("copilot-runtime-token");
|
|
expect(result.auth.apiKey).not.toBe("ghu_original_github_token");
|
|
});
|
|
|
|
it("applies exchanged copilot baseUrl to returned model", async () => {
|
|
hoisted.resolveModelMock.mockReturnValueOnce({
|
|
model: {
|
|
provider: "github-copilot",
|
|
id: "gpt-4.1",
|
|
},
|
|
authStorage: {
|
|
setRuntimeApiKey: hoisted.setRuntimeApiKeyMock,
|
|
},
|
|
modelRegistry: {},
|
|
});
|
|
hoisted.getApiKeyForModelMock.mockResolvedValueOnce({
|
|
apiKey: "ghu_test",
|
|
source: "profile:github-copilot:default",
|
|
mode: "token",
|
|
});
|
|
hoisted.resolveCopilotApiTokenMock.mockResolvedValueOnce({
|
|
token: "copilot-runtime-token",
|
|
expiresAt: Date.now() + 60_000,
|
|
source: "cache:/tmp/copilot-token.json",
|
|
baseUrl: "https://api.copilot.enterprise.example",
|
|
});
|
|
|
|
const result = await prepareSimpleCompletionModel({
|
|
cfg: undefined,
|
|
provider: "github-copilot",
|
|
modelId: "gpt-4.1",
|
|
});
|
|
|
|
expect(result).not.toHaveProperty("error");
|
|
if ("error" in result) {
|
|
return;
|
|
}
|
|
expect(result.model).toEqual(
|
|
expect.objectContaining({
|
|
baseUrl: "https://api.copilot.enterprise.example",
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("returns error when getApiKeyForModel throws", async () => {
|
|
hoisted.getApiKeyForModelMock.mockRejectedValueOnce(new Error("Profile not found: copilot"));
|
|
|
|
const result = await prepareSimpleCompletionModel({
|
|
cfg: undefined,
|
|
provider: "anthropic",
|
|
modelId: "claude-opus-4-6",
|
|
});
|
|
|
|
expect(result).toEqual({
|
|
error: 'Auth lookup failed for provider "anthropic": Profile not found: copilot',
|
|
});
|
|
expect(hoisted.setRuntimeApiKeyMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("applies local no-auth header override before returning model", async () => {
|
|
hoisted.resolveModelMock.mockReturnValueOnce({
|
|
model: {
|
|
provider: "local-openai",
|
|
id: "chat-local",
|
|
api: "openai-completions",
|
|
},
|
|
authStorage: {
|
|
setRuntimeApiKey: hoisted.setRuntimeApiKeyMock,
|
|
},
|
|
modelRegistry: {},
|
|
});
|
|
hoisted.getApiKeyForModelMock.mockResolvedValueOnce({
|
|
apiKey: "custom-local",
|
|
source: "models.providers.local-openai (synthetic local key)",
|
|
mode: "api-key",
|
|
});
|
|
hoisted.applyLocalNoAuthHeaderOverrideMock.mockReturnValueOnce({
|
|
provider: "local-openai",
|
|
id: "chat-local",
|
|
api: "openai-completions",
|
|
headers: { Authorization: null },
|
|
});
|
|
|
|
const result = await prepareSimpleCompletionModel({
|
|
cfg: undefined,
|
|
provider: "local-openai",
|
|
modelId: "chat-local",
|
|
});
|
|
|
|
expect(hoisted.applyLocalNoAuthHeaderOverrideMock).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
provider: "local-openai",
|
|
id: "chat-local",
|
|
}),
|
|
expect.objectContaining({
|
|
apiKey: "custom-local",
|
|
source: "models.providers.local-openai (synthetic local key)",
|
|
mode: "api-key",
|
|
}),
|
|
);
|
|
expect(result).toEqual(
|
|
expect.objectContaining({
|
|
model: expect.objectContaining({
|
|
headers: expect.objectContaining({ Authorization: null }),
|
|
}),
|
|
}),
|
|
);
|
|
});
|
|
});
|