mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-27 09:52:25 +00:00
* feat(discord): add autoThreadName 'generated' strategy
Adds async thread title generation for auto-created threads:
- autoThread: boolean - enables/disables auto-threading
- autoThreadName: 'message' | 'generated' - naming strategy
- 'generated' uses LLM to create concise 3-6 word titles
- Includes channel name/description context for better titles
- 10s timeout with graceful fallback
* Discord: support non-key auth for generated thread titles
* Discord: skip fallback auto-thread rename
* Discord: normalize generated thread title first content line
* Discord: split thread title generation helpers
* Discord: tidy thread title generation constants and order
* Discord: use runtime fallback model resolution for thread titles
* Discord: resolve thread-title model aliases
* Discord: fallback thread-title model selection to runtime defaults
* Agents: centralize simple completion runtime
* fix(discord): pass apiKey to complete() for thread title generation
The setRuntimeApiKey approach only works for full agent runs that use
authStorage.getApiKey(). The pi-ai complete() function expects apiKey
directly in options or falls back to env vars — it doesn't read from
authStorage.runtimeOverrides.
Fixes thread title generation for Claude/Anthropic users.
* fix(agents): return exchanged Copilot token from prepareSimpleCompletionModel
The recent thread-title fix (3346ba6) passes prepared.auth.apiKey to
complete(). For github-copilot, this was still the raw GitHub token
rather than the exchanged runtime token, causing auth failures.
Now setRuntimeApiKeyForCompletion returns the resolved token and
prepareSimpleCompletionModel includes it in auth.apiKey, so both the
authStorage path and direct apiKey pass-through work correctly.
* fix(agents): catch auth lookup exceptions in completion model prep
getApiKeyForModel can throw for credential issues (missing profile, etc).
Wrap in try/catch to return { error } for fail-soft handling rather than
propagating rejected promises to callers like thread title generation.
* Discord: strip markdown wrappers from generated thread titles
* Discord/agents: align thread-title model and local no-auth completion headers
* Tests: import fresh modules for mocked thread-title/simple-completion suites
* Agents: apply exchanged Copilot baseUrl in simple completions
* Discord: route thread runtime imports through plugin SDK
* Lockfile: add Discord pi-ai runtime dependency
* Lockfile: regenerate Discord pi-ai runtime dependency entries
* Agents: use published Copilot token runtime module
* Discord: refresh config baseline and lockfile
* Tests: split extension runs by isolation
* Discord: add changelog for generated thread titles (#43366) (thanks @davidguttman)
---------
Co-authored-by: Onur Solmaz <onur@textcortex.com>
Co-authored-by: Onur Solmaz <2453968+osolmaz@users.noreply.github.com>
342 lines
9.8 KiB
TypeScript
342 lines
9.8 KiB
TypeScript
import { 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;
|
|
|
|
beforeEach(async () => {
|
|
vi.resetModules();
|
|
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",
|
|
});
|
|
({ prepareSimpleCompletionModel } = await import("./simple-completion-runtime.js"));
|
|
});
|
|
|
|
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-5",
|
|
},
|
|
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-5",
|
|
allowMissingApiKeyModes: ["aws-sdk"],
|
|
});
|
|
|
|
expect(result).toEqual(
|
|
expect.objectContaining({
|
|
model: expect.objectContaining({
|
|
provider: "amazon-bedrock",
|
|
id: "anthropic.claude-sonnet-4-5",
|
|
}),
|
|
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 }),
|
|
}),
|
|
}),
|
|
);
|
|
});
|
|
});
|