Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
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
1 change: 1 addition & 0 deletions apps/server/src/observability/RpcInstrumentation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ const RPC_AGGREGATES = {
[WS_METHODS.serverGetResourceTelemetryHistory]: "server",
[WS_METHODS.serverRetryResourceTelemetry]: "server",
[WS_METHODS.serverCheckOtlpEndpoint]: "server",
[WS_METHODS.serverListTextGeneratorModels]: "server",
[WS_METHODS.serverPrepareTriage]: "server",
[WS_METHODS.serverGetUsageSummary]: "server",
[WS_METHODS.serverRefreshUsageRates]: "server",
Expand Down
2 changes: 2 additions & 0 deletions apps/server/src/policy/managedProviders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -389,6 +389,8 @@ export function makeManagedProviders(
const result: ServerSettings = {
...settings,
providerInstances: providerInstances as ServerSettings["providerInstances"],
// An allow-list names agent drivers; text generators would send code to any other API.
...(allowed === undefined ? {} : { textGenerators: {} }),
sourceControlWriterModelSelection: allowedSelection(
settings,
settings.sourceControlWriterModelSelection,
Expand Down
13 changes: 13 additions & 0 deletions apps/server/src/serverSettings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1751,8 +1751,17 @@ it.layer(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))("server settings"
const antigravity = ProviderInstanceId.make("antigravity");
const openCode = ProviderInstanceId.make("opencode");
const hub = UsageLimitSourceId.make("hub");
const router = ProviderInstanceId.make("openrouter");
const withUsage = yield* serverSettings.updateSettings({
cloudSpeechCredentials: { openai: usageReference, xai: "literal-fixture" },
textGenerators: {
[router]: {
kind: "openaiCompatible",
baseUrl: "https://openrouter.ai/api/v1",
apiKey: usageReference,
enabled: true,
},
},
usageLimitSources: {
[hub]: {
kind: "cliproxy",
Expand Down Expand Up @@ -1805,6 +1814,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))("server settings"
});
assert.equal(legacy.sourceControlHosts.bitbucket?.accessToken, SECRET_REDACTED);
assert.equal(legacy.usageLimitSources[hub]?.managementKey, SECRET_REDACTED);
assert.equal(legacy.textGenerators[router]?.apiKey, SECRET_REDACTED);
assert.equal(legacy.easDeviceHosts[0]?.accessToken, SECRET_REDACTED);
assert.equal(legacy.simlockDeviceHosts[0]?.agentToken, SECRET_REDACTED);
const configOf = (settings: ServerSettings, id: ProviderInstanceId) =>
Expand All @@ -1815,6 +1825,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))("server settings"
cloudSpeechCredentials: legacy.cloudSpeechCredentials,
sourceControlHosts: legacy.sourceControlHosts,
usageLimitSources: legacy.usageLimitSources,
textGenerators: legacy.textGenerators,
easDeviceHosts: legacy.easDeviceHosts,
simlockDeviceHosts: legacy.simlockDeviceHosts,
providerInstances: legacy.providerInstances,
Expand All @@ -1825,6 +1836,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))("server settings"
"ghe.acme.test": "ghp_ghe",
});
assert.deepEqual(echoed.usageLimitSources[hub]?.managementKey, usageReference);
assert.deepEqual(echoed.textGenerators[router]?.apiKey, usageReference);
assert.deepEqual(echoed.cloudSpeechCredentials, {
openai: usageReference,
xai: "literal-fixture",
Expand Down Expand Up @@ -1930,6 +1942,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))("server settings"
"easDeviceHosts.[].accessToken",
"simlockDeviceHosts.[].agentToken",
"usageLimitSources.*.managementKey",
"textGenerators.*.apiKey",
]);
}),
);
Expand Down
16 changes: 16 additions & 0 deletions apps/server/src/settingsSecrets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
type ServerSettings,
type SettingsSecretValue,
SimlockDeviceHostConfig,
TextGeneratorConfig,
UsageLimitSourceConfig,
isSecretReference,
secretSettingsFields,
Expand Down Expand Up @@ -46,6 +47,7 @@ const providerSecretFields = new Map([
),
]);
const usageLimitSecretFields = secretSettingsFields(UsageLimitSourceConfig);
const textGeneratorSecretFields = secretSettingsFields(TextGeneratorConfig);
const voiceSecretFields = secretSettingsFields(CloudSpeechCredentials);
const easDeviceHostSecretFields = secretSettingsFields(EasDeviceHostConfig);
const simlockDeviceHostSecretFields = secretSettingsFields(SimlockDeviceHostConfig);
Expand Down Expand Up @@ -209,6 +211,19 @@ function mapSecretFieldGroups(
]),
) as ServerSettings["usageLimitSources"];

const textGenerators = Object.fromEntries(
Object.entries(settings.textGenerators).map(([id, generator]) => [
id,
mapFields(
generator,
textGeneratorSecretFields,
(field, serverHost) =>
`text-generator-${encode(id)}-${encode(field)}` +
(serverHost === null ? "" : `-${encode(serverHost)}`),
),
]),
) as ServerSettings["textGenerators"];

const easDeviceHosts = settings.easDeviceHosts.map(
(host) =>
mapFields(
Expand Down Expand Up @@ -242,6 +257,7 @@ function mapSecretFieldGroups(
providerInstances,
sourceControlHosts,
usageLimitSources,
textGenerators,
easDeviceHosts,
simlockDeviceHosts,
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,7 @@ it.effect("reads a collected reference again once it is invalidated", () =>
sourceControlHosts: hosts,
cloudSpeechCredentials: { openai: source("voice#key"), xai: "", openrouter: "" },
usageLimitSources,
textGenerators: {},
easDeviceHosts,
simlockDeviceHosts,
}),
Expand Down
4 changes: 4 additions & 0 deletions apps/server/src/sourceControl/sourceControlHostSecrets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ export const invalidateSettingsSecrets = Effect.fn("SettingsSecrets.invalidate")
ServerSettings,
| "sourceControlHosts"
| "usageLimitSources"
| "textGenerators"
| "easDeviceHosts"
| "simlockDeviceHosts"
| "cloudSpeechCredentials"
Expand All @@ -131,6 +132,9 @@ export const invalidateSettingsSecrets = Effect.fn("SettingsSecrets.invalidate")
Effect.map((current) => [
...collectSourceControlHostSecretReferences(current.sourceControlHosts),
...collectUsageLimitSecretReferences(current.usageLimitSources),
...Object.values(current.textGenerators).flatMap(({ apiKey }) =>
isSecretReference(apiKey) ? [apiKey] : [],
),
...collectDeviceHostSecretReferences(current),
...Object.values(current.cloudSpeechCredentials).filter(isSecretReference),
]),
Expand Down
89 changes: 89 additions & 0 deletions apps/server/src/textGeneration/CodexTextGeneration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import { CodexSettings, ProviderInstanceId } from "@t3tools/contracts";
import * as ServerConfig from "../config.ts";
import { makeProcessProbe, processProbeSource } from "../testUtils/processProbe.ts";
import * as TextGeneration from "./TextGeneration.ts";
import * as TextGenerators from "./TextGenerators.ts";
import { makeCodexTextGeneration } from "./CodexTextGeneration.ts";
import { writeFakeCli } from "@t3tools/provider-testing/fakeCli";
const decodeCodexSettings = Schema.decodeSync(CodexSettings);
Expand Down Expand Up @@ -625,6 +626,7 @@ it.effect.each(PREFLIGHT_PRIMARY_STATES)("text generation preflight: %s", (prima
[fallback]: { driver: ProviderDriverKind.make("claudeAgent"), config: {} },
},
}),
Layer.mock(TextGenerators.TextGenerators)({ runner: () => Effect.undefined }),
Layer.mock(SourceControlProviderRegistry.SourceControlProviderRegistry)({
resolveLink: () => Effect.die("unused"),
}),
Expand Down Expand Up @@ -652,3 +654,90 @@ it.effect.each(PREFLIGHT_PRIMARY_STATES)("text generation preflight: %s", (prima
]);
}),
);

it.effect(
"a review guide aimed at a text generator falls back to an agent that can read the checkout",
() =>
Effect.gen(function* () {
const claude = ProviderInstanceId.make("claudeAgent");
const driver = ProviderDriverKind.make("claudeAgent");
const calls: ModelSelection[] = [];
const instance = {
instanceId: claude,
driverKind: driver,
continuationIdentity: { driverKind: driver, continuationKey: claude },
displayName: undefined,
enabled: true,
snapshot: {
getSnapshot: Effect.succeed<ServerProvider>({
instanceId: claude,
driver,
enabled: true,
installed: true,
status: "ready",
auth: { status: "authenticated" },
version: null,
checkedAt: "1970-01-01T00:00:00Z",
models: [],
slashCommands: [],
skills: [],
}),
} as ProviderInstance["snapshot"],
orchestrationAdapter: {} as ProviderInstance["orchestrationAdapter"],
textGeneration: TextGeneration.TextGeneration.of({
generateCommitMessage: () => Effect.die("unused"),
generatePrContent: () => Effect.die("unused"),
generateBranchName: () => Effect.die("unused"),
generateThreadTitle: () => Effect.die("unused"),
generateTranscriptionPostProcessing: () => Effect.die("unused"),
generateReviewGuide: (input) =>
Effect.sync(() => {
calls.push(input.modelSelection);
return {} as TextGeneration.ReviewGuideGenerationResult;
}),
}),
} satisfies ProviderInstance;
const serviceWith = (instances: ReadonlyArray<ProviderInstance>) =>
TextGeneration.make.pipe(
Effect.provide(
Layer.mergeAll(
Layer.mock(ProviderInstanceRegistry.ProviderInstanceRegistry)({
listInstances: Effect.succeed(instances),
getInstance: (id) =>
Effect.succeed(instances.find((candidate) => candidate.instanceId === id)),
}),
ServerSettings.layerTest({}),
Layer.mock(TextGenerators.TextGenerators)({
runner: (id) =>
Effect.succeed(
id === "openrouter"
? () => Effect.die("a text generator cannot investigate")
: undefined,
),
}),
Layer.mock(SourceControlProviderRegistry.SourceControlProviderRegistry)({
resolveLink: () => Effect.die("unused"),
}),
),
),
);
const input = {
cwd: "/tmp",
number: 1,
title: "Change",
description: "",
baseBranch: "main",
baseCommit: "abc",
settledReview: "",
modelSelection: { instanceId: ProviderInstanceId.make("openrouter"), model: "some/model" },
};
yield* (yield* serviceWith([instance])).generateReviewGuide(input);
expect(calls.map((selection) => selection.instanceId)).toEqual([claude]);

// With no agent ready, it says what is missing instead of looking up a provider by that id.
const failure = yield* Effect.flip((yield* serviceWith([])).generateReviewGuide(input));
expect(failure.detail).toMatch(
/^Review guides need a coding agent that can read the checkout/,
);
}),
);
32 changes: 28 additions & 4 deletions apps/server/src/textGeneration/TextGeneration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ import * as ProviderInstanceRegistry from "../provider/ProviderInstanceRegistry.
import type { ProviderInstance } from "@t3tools/provider-core/server/driver";
import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts";
import * as ThreadTitleLinks from "./ThreadTitleLinks.ts";
import * as TextGenerators from "./TextGenerators.ts";
import * as TextGenerationOperations from "@t3tools/provider-core/server/textGenerationOperations";

export type {
BranchNameGenerationInput,
Expand Down Expand Up @@ -75,6 +77,7 @@ export const make = Effect.gen(function* () {
const registry = yield* ProviderInstanceRegistry.ProviderInstanceRegistry;
const sourceControl = yield* SourceControlProviderRegistry.SourceControlProviderRegistry;
const serverSettings = yield* ServerSettings.ServerSettingsService;
const textGenerators = yield* TextGenerators.TextGenerators;
const generate = Effect.fn("TextGeneration.generate")(function* <A>(
operation: TextGenerationOp,
selection: ModelSelection,
Expand All @@ -83,6 +86,17 @@ export const make = Effect.gen(function* () {
modelSelection: ModelSelection,
) => Effect.Effect<A, TextGenerationError>,
) {
const textGenerator = yield* textGenerators.runner(selection.instanceId);
// A review guide reaches a text generator only through a fallback; it needs an agent instead.
const textGeneratorId = selection.instanceId;
const textGeneratorCannotRun =
textGenerator !== undefined && operation === "generateReviewGuide";
if (textGenerator !== undefined && !textGeneratorCannotRun) {
return yield* invoke(
TextGenerationOperations.fromRunner("TextGenerator", textGenerator),
selection,
);
}
const snapshots = yield* registry.listInstances.pipe(
Effect.flatMap((instances) =>
Effect.forEach(instances, (instance) => instance.snapshot.getSnapshot),
Expand All @@ -92,9 +106,10 @@ export const make = Effect.gen(function* () {
const selected = snapshots.find((provider) => provider.instanceId === selection.instanceId);
// Only a failed probe counts: a pending one is also not installed yet.
const unusable =
selected?.enabled === true &&
selected.status === "error" &&
(!selected.installed || selected.auth.status === "unauthenticated");
textGeneratorCannotRun ||
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
(selected?.enabled === true &&
selected.status === "error" &&
(!selected.installed || selected.auth.status === "unauthenticated"));
if (unusable || exhaustedUsageWindow(selected, now, selection.model)) {
const settings = yield* serverSettings.getSettings.pipe(
Effect.mapError(
Expand Down Expand Up @@ -127,6 +142,13 @@ export const make = Effect.gen(function* () {
{ includeConfiguredInstances: true },
) ?? selection;
}
if (textGeneratorCannotRun && selection.instanceId === textGeneratorId) {
return yield* new TextGenerationError({
operation,
detail:
"Review guides need a coding agent that can read the checkout. None is ready. Pick Codex or Claude as the review guide model in Settings → Source control.",
});
}
return yield* resolveInstance(registry, operation, selection.instanceId).pipe(
Effect.flatMap((service) => invoke(service, selection)),
);
Expand Down Expand Up @@ -187,4 +209,6 @@ export const make = Effect.gen(function* () {
});
});

export const layer = Layer.effect(TextGeneration, make);
export const layer = Layer.effect(TextGeneration, make).pipe(
Layer.provideMerge(TextGenerators.layer),
);
Loading
Loading