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
469 changes: 247 additions & 222 deletions packages/client/src/generated-effect/client.ts

Large diffs are not rendered by default.

30 changes: 30 additions & 0 deletions packages/client/src/generated/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,10 @@ import type {
ProvidersListOutput,
ProvidersGetInput,
ProvidersGetOutput,
ServerVariantParaphraseInput,
ServerVariantParaphraseOutput,
ServerVariantCompareInput,
ServerVariantCompareOutput,
IntegrationsListInput,
IntegrationsListOutput,
IntegrationsGetInput,
Expand Down Expand Up @@ -547,6 +551,32 @@ export function make(options: ClientOptions) {
requestOptions,
),
},
"server.variant": {
paraphrase: (input: ServerVariantParaphraseInput, requestOptions?: RequestOptions) =>
request<ServerVariantParaphraseOutput>(
{
method: "POST",
path: `/api/variant/paraphrase`,
body: { text: input["text"], models: input["models"], requestId: input["requestId"] },
successStatus: 200,
declaredStatuses: [400, 401, 404, 503],
empty: false,
},
requestOptions,
),
compare: (input: ServerVariantCompareInput, requestOptions?: RequestOptions) =>
request<ServerVariantCompareOutput>(
{
method: "POST",
path: `/api/variant/compare`,
body: { messages: input["messages"], models: input["models"], requestId: input["requestId"] },
successStatus: 200,
declaredStatuses: [400, 401, 404, 503],
empty: false,
},
requestOptions,
),
},
integrations: {
list: (input?: IntegrationsListInput, requestOptions?: RequestOptions) =>
request<IntegrationsListOutput>(
Expand Down
70 changes: 70 additions & 0 deletions packages/client/src/generated/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2057,6 +2057,76 @@ export type ProvidersGetOutput = {
}
}

export type ServerVariantParaphraseInput = {
readonly text: {
readonly text: string
readonly models: ReadonlyArray<{ readonly model: string; readonly variant?: string }>
readonly requestId?: string
}["text"]
readonly models: {
readonly text: string
readonly models: ReadonlyArray<{ readonly model: string; readonly variant?: string }>
readonly requestId?: string
}["models"]
readonly requestId?: {
readonly text: string
readonly models: ReadonlyArray<{ readonly model: string; readonly variant?: string }>
readonly requestId?: string
}["requestId"]
}

export type ServerVariantParaphraseOutput = {
readonly variants: ReadonlyArray<{
readonly slot: number
readonly model: string
readonly text?: string
readonly error?: string
readonly redrob?: {
readonly requestId?: string
readonly routedModel?: string
readonly upstreamProvider?: string
readonly latencyMs?: number
readonly costUsd?: number
}
}>
readonly totalCostUsd: number
}

export type ServerVariantCompareInput = {
readonly messages: {
readonly messages: ReadonlyArray<{ readonly role: "system" | "user" | "assistant"; readonly content: string }>
readonly models: ReadonlyArray<{ readonly model: string; readonly variant?: string }>
readonly requestId?: string
}["messages"]
readonly models: {
readonly messages: ReadonlyArray<{ readonly role: "system" | "user" | "assistant"; readonly content: string }>
readonly models: ReadonlyArray<{ readonly model: string; readonly variant?: string }>
readonly requestId?: string
}["models"]
readonly requestId?: {
readonly messages: ReadonlyArray<{ readonly role: "system" | "user" | "assistant"; readonly content: string }>
readonly models: ReadonlyArray<{ readonly model: string; readonly variant?: string }>
readonly requestId?: string
}["requestId"]
}

export type ServerVariantCompareOutput = {
readonly variants: ReadonlyArray<{
readonly slot: number
readonly model: string
readonly text?: string
readonly error?: string
readonly redrob?: {
readonly requestId?: string
readonly routedModel?: string
readonly upstreamProvider?: string
readonly latencyMs?: number
readonly costUsd?: number
}
}>
readonly totalCostUsd: number
}

export type IntegrationsListInput = {
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
Expand Down
76 changes: 76 additions & 0 deletions packages/core/src/console-key.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
export * as ConsoleKey from "./console-key"

import { join } from "node:path"
import { Effect } from "effect"

import { Global } from "./global"
import { Integration } from "./integration"

/**
* The console API key, from wherever the user actually put it.
*
* THREE ORIGINS, and they are not interchangeable historically:
*
* - `REDROB_API_KEY` is what a terminal user exports.
* - The Credential store is what `redrob providers login` writes. It is SQL.
* - `auth.json` is what `PUT /auth/redrob` writes, which is the route the DESKTOP APP uses for both of
* its connect paths -- the pasted key and "Connect Redrob". That flow ends by handing the key to this
* same route, so the app never populates the Credential store at all.
*
* Nothing bridges the last two: one is SQL, the other a file, and no migration copies either into the
* other. So every consumer has to read all three.
*
* This lives in its own module because reading only some of them is a bug that has already shipped once:
* the dynamic model catalogue checked the environment and the Credential store, so a user who connected
* through the desktop app had a key present, in `auth.json`, that the catalogue could not see -- it took
* the keyless branch and silently served the six-id fallback list. A second consumer re-deriving this
* logic is how that happens again, so there is one implementation and both callers use it.
*
* Note the SHAPES differ as well as the locations: the Credential store discriminates on `type: "key"`,
* `auth.json` on `type: "api"`. Matching only one spelling was the second half of that same bug.
*/

/** `auth.json`, the desktop app's store. Absent or malformed reads as "no key" rather than failing. */
function fromAuthStore(): Effect.Effect<string | undefined> {
return Effect.tryPromise(() => Bun.file(join(Global.Path.data, "auth.json")).json()).pipe(
Effect.map((data) => {
if (typeof data !== "object" || data === null) return undefined
const entry = (data as Record<string, unknown>)["redrob"]
if (typeof entry !== "object" || entry === null) return undefined
const record = entry as Record<string, unknown>
if (record["type"] !== "api") return undefined
const key = record["key"]
return typeof key === "string" && key.trim() ? key : undefined
}),
Effect.orElseSucceed(() => undefined),
)
}

/**
* Resolve the key, in the order a user would expect to win.
*
* The environment first, because an explicitly exported value is the one a person set most recently and
* most deliberately -- overriding a stored credential for one run is a thing people do, and the reverse
* would make that impossible.
*/
export const resolve = Effect.fn("ConsoleKey.resolve")(function* () {
const fromEnv = process.env["REDROB_API_KEY"]
if (fromEnv) return fromEnv

/*
Read through `Integration`, not `Credential` directly. Both reach the same store, but `Credential` is not
in scope everywhere this runs -- the HTTP handlers have `Integration` and resolving through it returns
the same material. Using the lower-level service typechecked and then failed at request time with
"Service not found", which is the kind of error a type system cannot catch for you.
*/
const integration = yield* Integration.Service
const connection = yield* integration.connection.active(Integration.ID.make("redrob"))
if (connection) {
const value = yield* integration.connection
.resolve(connection)
.pipe(Effect.orElseSucceed(() => undefined))
if (value?.type === "key" && value.key.trim()) return value.key
}

return yield* fromAuthStore()
})
11 changes: 11 additions & 0 deletions packages/core/src/flag/flag.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,17 @@ export const Flag = {

REDROB_AUTO_HEAP_SNAPSHOT: truthy("REDROB_AUTO_HEAP_SNAPSHOT"),
REDROB_GIT_BASH_PATH: process.env["REDROB_GIT_BASH_PATH"],
/**
* Point the console calls at a different base URL.
*
* Exists for verifying against a console running locally. Without it the only way to exercise a change
* to those endpoints is to deploy it, which is how a broken `/variants/paraphrase` reached production and
* stayed there: nothing could call it except the real thing.
*
* Never set in a shipped build, and it does not change where credentials come from -- a local console
* still wants a key it recognises.
*/
REDROB_CONSOLE_URL: process.env["REDROB_CONSOLE_URL"],
REDROB_CONFIG: process.env["REDROB_CONFIG"],
REDROB_CONFIG_CONTENT: process.env["REDROB_CONFIG_CONTENT"],
REDROB_DISABLE_AUTOUPDATE: truthy("REDROB_DISABLE_AUTOUPDATE"),
Expand Down
19 changes: 18 additions & 1 deletion packages/core/src/plugin/provider/redrob-constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,28 @@
// ModelsDev.Service (packages/core/src/models-dev.ts) can import them without creating
// an import cycle through the heavier plugin/event modules.

import { Flag } from "../../flag/flag"

// The real console.redrob.ai API is an OpenAI-standard base URL. SDKs and the dynamic
// catalog fetch append paths to it: /chat/completions for inference and /models for the
// OpenAI-standard model listing. It is served by the trusted @ai-sdk/openai-compatible
// package (pinned by ConfigProviderPlugin).
export const CONSOLE_URL = "https://console.redrob.ai/api/backend/v1"
//
// Resolved HERE rather than at each call site, because a per-consumer `Flag.REDROB_CONSOLE_URL ??
// CONSOLE_URL` is what made the override half-apply: the variants service honoured it while the
// session's own inference path, the catalog fetch and the provider registration all still went to
// production. Pointing the CLI at a local console then produced a console that answered
// `/variants/paraphrase` locally and 401'd every chat turn against the real one -- a split that looks
// like a credential bug and is not.
//
// The flag is a developer seam, never set in a shipped build, and it does not change where
// credentials come from: a local console still wants a key it recognises. Importing Flag is safe from
// this leaf module because `flag/flag.ts` imports only `effect` -- the cycle this file avoids is
// through the plugin and event modules, which Flag does not touch.
export const CONSOLE_URL = (Flag.REDROB_CONSOLE_URL ?? "https://console.redrob.ai/api/backend/v1").replace(
/\/+$/,
"",
)
export const CONSOLE_PACKAGE = "@ai-sdk/openai-compatible"
// Every served console model publishes `capabilities.maxContextTokens: 1_000_000`. Held as one
// constant because the value is uniform across the six ids, and used as the no-key fallback
Expand Down
149 changes: 149 additions & 0 deletions packages/core/src/variants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
export * as Variants from "./variants"

import { Effect, Schema } from "effect"
import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http"

import { Variant } from "@redrob-code/schema/variant"

import { ConsoleKey } from "./console-key"
import { CONSOLE_URL } from "./plugin/provider/redrob-constants"

/**
* The console's multi-model endpoints, reached once on behalf of every client.
*
* `/v1/variants/paraphrase` and `/v1/variants/compare` are not OpenAI-standard, so they cannot arrive
* through the `@ai-sdk/openai-compatible` path. Putting them here rather than in each caller keeps one
* place that knows how to find the credential, what the console's envelope looks like, and how its
* refusals map -- three things that would otherwise drift between the CLI and the desktop apps.
*
* WHAT IS DELIBERATELY PASSED THROUGH RATHER THAN SUMMARISED: the per-slot `redrob` block, carrying
* `routedModel` and `costUsd`. When a request names `auto`, `routedModel` is the only thing that says which
* model actually answered, and one request here makes several charges -- so a caller that wants to show a
* user what was used and what it cost needs both, per slot, not a total.
*/

/** No retries and no long wait: these are user-initiated and a caller can ask again. */
const REQUEST_TIMEOUT = "120 seconds"

export class NotConnected extends Error {
readonly _tag = "VariantsNotConnected"
constructor() {
super("the Redrob provider is not connected, so the console's multi-model endpoints are unavailable")
}
}

export class Refused extends Error {
readonly _tag = "VariantsRefused"
constructor(
readonly status: number,
message: string,
/** Seconds the console asked the caller to wait, when it said. Surfaced so a UI can say when to retry. */
readonly retryAfterSeconds?: number,
) {
super(message)
}
}

/**
* The console's own error envelope.
*
* Read rather than ignored because the message is the useful part: "This API key has spent $25.00 of its
* $25.00 monthly budget" tells a user what to do, and the status alone does not. A body that does not parse
* falls back to the status, which is still better than nothing.
*/
const ErrorEnvelope = Schema.Struct({
message: Schema.String.pipe(Schema.optional),
error: Schema.Struct({ message: Schema.String.pipe(Schema.optional) }).pipe(Schema.optional),
retryAfterSeconds: Schema.Finite.pipe(Schema.optional),
})

function describe(status: number, body: string): { message: string; retryAfterSeconds?: number } {
const parsed = Schema.decodeUnknownOption(Schema.fromJsonString(ErrorEnvelope))(body)
if (parsed._tag === "None") return { message: `the console refused the request with ${status}` }
const value = parsed.value
return {
message:
value.error?.message ?? value.message ?? `the console refused the request with ${status}`,
retryAfterSeconds: value.retryAfterSeconds,
}
}

const call = Effect.fn("Variants.call")(function* <A>(
path: string,
schema: Schema.Codec<A, unknown>,
payload: A,
) {
const apiKey = yield* ConsoleKey.resolve()
/*
No key means the provider is not connected, which is a different thing from a rejected key: there is
nothing to ask. Callers turn this into a 404 rather than a 401, so a user on a local runtime is told the
feature is not available instead of being sent to check a credential they never set.
*/
if (!apiKey) return yield* Effect.fail(new NotConnected())

const http = yield* HttpClient.HttpClient
/*
Encoded THROUGH THE SCHEMA rather than handed to a generic JSON body: the request shape is declared once
and this is what holds the wire format to it, so a field renamed in the schema cannot silently keep
sending the old name.
*/
/*
`CONSOLE_URL` already carries the developer override and is already trailing-slash trimmed. Resolving
the flag a second time here is what let the override apply to this service alone while the session's
inference path kept talking to production.
*/
const request = yield* HttpClientRequest.post(`${CONSOLE_URL}${path}`).pipe(
HttpClientRequest.bearerToken(apiKey),
HttpClientRequest.schemaBodyJson(schema)(payload),
)

const response = yield* http.execute(request).pipe(Effect.timeout(REQUEST_TIMEOUT))
const body = yield* response.text

if (response.status < 200 || response.status >= 300) {
const { message, retryAfterSeconds } = describe(response.status, body)
/*
`Retry-After` is preferred over the body figure when both are present: it is the header HTTP clients
are built around, and the console sets it on exactly the statuses where waiting is the remedy.
*/
const header = response.headers["retry-after"]
const fromHeader = typeof header === "string" ? Number(header) : Number.NaN
return yield* Effect.fail(
new Refused(
response.status,
message,
Number.isFinite(fromHeader) ? fromHeader : retryAfterSeconds,
),
)
}

const decoded = Schema.decodeUnknownOption(Schema.fromJsonString(Variant.Result))(body)
if (decoded._tag === "None") {
return yield* Effect.fail(new Refused(response.status, "could not read the console's response"))
}
return decoded.value
})

/*
The HTTP client is provided HERE rather than demanded from callers.

Without this the `HttpClient` requirement propagated out of these two functions, through the route
handler, into the API's own requirement type, and out to every entry point that builds the API -- the
`serve` command failed to typecheck with `Type 'HttpClient' is not assignable to type 'Service'`, which
reads as a problem with `serve` and is not one. A leaf that makes one outbound call should not widen the
contract of everything above it.

The repository's usual shape for this is a service node with `deps: () => [..., httpClient]`, which
shares one client process-wide. These are two plain functions rather than a service, so they take their
own fetch client instead. That is a real difference -- no shared connection pooling with the rest of the
CLI -- and it is proportionate for two endpoints called only on an explicit user action. Turning this
into a service node is the right move if it ever grows a third caller.
*/
const withHttp = <A, E, R>(effect: Effect.Effect<A, E, R | HttpClient.HttpClient>) =>
Effect.provide(effect, FetchHttpClient.layer)

export const paraphrase = (request: Variant.ParaphraseRequest) =>
withHttp(call("/variants/paraphrase", Variant.ParaphraseRequest, request))

export const compare = (request: Variant.CompareRequest) =>
withHttp(call("/variants/compare", Variant.CompareRequest, request))
Loading
Loading