Skip to content
Merged
23 changes: 22 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,12 @@ jobs:
# Separate from `signed`: the binaries can be signed while the installer is not, and the
# notes must not round one up into the other.
installer: ${{ steps.installer-result.outputs.installer }}
# WHICH method signed, not just whether something did. The notes used to name Azure
# Trusted Signing unconditionally while this repository signs with the organization
# Authenticode certificate, so every published release claimed a method it had not
# used. `signed` cannot carry that -- it is a boolean about success -- and a reader has
# no way to check, which is exactly the failure the comment above warns about.
method: ${{ steps.config.outputs.method }}
env:
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
Expand Down Expand Up @@ -676,6 +682,7 @@ jobs:
UPSTREAM: ${{ needs.version.outputs.upstream }}
WINDOWS_SIGNED: ${{ needs.sign-windows.outputs.signed }}
WINDOWS_INSTALLER: ${{ needs.sign-windows.outputs.installer }}
WINDOWS_SIGNING_METHOD: ${{ needs.sign-windows.outputs.method }}
run: |
set -euo pipefail
# An annotated tag records a tagger, and a bare runner has no git identity, so
Expand Down Expand Up @@ -721,7 +728,21 @@ jobs:
echo
fi
if [ "$WINDOWS_SIGNED" = "true" ]; then
echo "Windows binaries are signed through Azure Trusted Signing."
# Named from what the run actually did. An unrecognised value is described
# without naming a method rather than guessed at: a wrong provenance claim is
# worse than a vaguer true one, because a reader cannot tell it is wrong.
case "${WINDOWS_SIGNING_METHOD:-}" in
azure)
echo "Windows binaries are signed through Azure Trusted Signing."
;;
pfx)
echo "Windows binaries are signed with the Redrob Authenticode certificate."
;;
*)
echo "Windows binaries are signed. Verify the signature with \`signtool verify /pa\`"
echo "or against the provenance attestation below."
;;
esac
else
echo "**Windows binaries in this release are NOT signed**, so Windows will show a"
echo "SmartScreen warning on first run. Verify the archive against \`SHA256SUMS\`"
Expand Down
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
Loading
Loading