Skip to content

Commit 57dfe15

Browse files
suryaiyer95mdesmet
authored andcommitted
feat: add Altimate provider with /login command and credential validation
1 parent 5d0ada3 commit 57dfe15

8 files changed

Lines changed: 633 additions & 3 deletions

File tree

docs/docs/getting-started.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,24 @@ Add a warehouse connection to `.altimate-code/connections.json`. Here's a quick
8181

8282
For all warehouse types (Snowflake, BigQuery, Databricks, PostgreSQL, Redshift, DuckDB, MySQL, SQL Server) and advanced options (key-pair auth, ADC, SSH tunneling), see the [Warehouses reference](configure/warehouses.md).
8383

84+
### Connecting to Altimate
85+
86+
If you have an Altimate platform account, create `~/.altimate/altimate.json` with your credentials:
87+
88+
```json
89+
{
90+
"altimateInstanceName": "your-instance",
91+
"altimateApiKey": "your-api-key",
92+
"altimateUrl": "https://api.myaltimate.com"
93+
}
94+
```
95+
96+
- **Instance Name** — the subdomain from your Altimate dashboard URL (e.g. `acme` from `https://acme.app.myaltimate.com`)
97+
- **API Key** — go to **Settings > API Keys** in your Altimate dashboard and click **Copy**
98+
- **URL** — matches your dashboard domain: if you access `https://<instance>.app.myaltimate.com`, use `https://api.myaltimate.com`; if `https://<instance>.app.getaltimate.com`, use `https://api.getaltimate.com`
99+
100+
Then run `/connect` and select **Altimate** to start using it as your model.
101+
84102
## Step 4: Choose an Agent Mode
85103

86104
altimate offers specialized agent modes for different workflows:

packages/opencode/src/altimate/api/client.ts

Lines changed: 82 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,15 +54,96 @@ export namespace AltimateApi {
5454
return Filesystem.exists(credentialsPath())
5555
}
5656

57+
function resolveEnvVars(obj: unknown): unknown {
58+
if (typeof obj === "string") {
59+
return obj.replace(/\$\{env:([^}]+)\}/g, (_, envVar) => {
60+
const value = process.env[envVar]
61+
if (!value) throw new Error(`Environment variable ${envVar} not found`)
62+
return value
63+
})
64+
}
65+
if (Array.isArray(obj)) return obj.map(resolveEnvVars)
66+
if (obj && typeof obj === "object")
67+
return Object.fromEntries(Object.entries(obj).map(([k, v]) => [k, resolveEnvVars(v)]))
68+
return obj
69+
}
70+
5771
export async function getCredentials(): Promise<AltimateCredentials> {
5872
const p = credentialsPath()
5973
if (!(await Filesystem.exists(p))) {
6074
throw new Error(`Altimate credentials not found at ${p}`)
6175
}
62-
const raw = JSON.parse(await Filesystem.readText(p))
76+
const raw = resolveEnvVars(JSON.parse(await Filesystem.readText(p)))
6377
return AltimateCredentials.parse(raw)
6478
}
6579

80+
export function parseAltimateKey(value: string): {
81+
altimateUrl: string
82+
altimateInstanceName: string
83+
altimateApiKey: string
84+
} | null {
85+
const parts = value.trim().split("::")
86+
if (parts.length < 3) return null
87+
const url = parts[0].trim()
88+
const instance = parts[1].trim()
89+
const key = parts.slice(2).join("::").trim()
90+
if (!url || !instance || !key) return null
91+
if (!url.startsWith("http://") && !url.startsWith("https://")) return null
92+
return { altimateUrl: url, altimateInstanceName: instance, altimateApiKey: key }
93+
}
94+
95+
export async function saveCredentials(creds: {
96+
altimateUrl: string
97+
altimateInstanceName: string
98+
altimateApiKey: string
99+
mcpServerUrl?: string
100+
}): Promise<void> {
101+
await Filesystem.writeJson(credentialsPath(), creds, 0o600)
102+
}
103+
104+
const VALID_TENANT_REGEX = /^[a-z_][a-z0-9_-]*$/
105+
106+
/** Validates credentials against the Altimate API.
107+
* Mirrors AltimateSettingsHelper.validateSettings from altimate-mcp-engine. */
108+
export async function validateCredentials(creds: {
109+
altimateUrl: string
110+
altimateInstanceName: string
111+
altimateApiKey: string
112+
}): Promise<{ ok: true } | { ok: false; error: string }> {
113+
if (!VALID_TENANT_REGEX.test(creds.altimateInstanceName)) {
114+
return {
115+
ok: false,
116+
error:
117+
"Invalid instance name (must be lowercase letters, numbers, underscores, hyphens, starting with letter or underscore)",
118+
}
119+
}
120+
try {
121+
const url = `${creds.altimateUrl.replace(/\/+$/, "")}/dbt/v3/validate-credentials`
122+
const res = await fetch(url, {
123+
method: "GET",
124+
headers: {
125+
"x-tenant": creds.altimateInstanceName,
126+
Authorization: `Bearer ${creds.altimateApiKey}`,
127+
"Content-Type": "application/json",
128+
},
129+
})
130+
if (res.status === 401) {
131+
const body = await res.text()
132+
return { ok: false, error: `Invalid API key - ${body}` }
133+
}
134+
if (res.status === 403) {
135+
const body = await res.text()
136+
return { ok: false, error: `Invalid instance name - ${body}` }
137+
}
138+
if (!res.ok) {
139+
return { ok: false, error: `Connection failed (${res.status} ${res.statusText})` }
140+
}
141+
return { ok: true }
142+
} catch {
143+
return { ok: false, error: "Could not reach Altimate API" }
144+
}
145+
}
146+
66147
async function request(creds: AltimateCredentials, method: string, endpoint: string, body?: unknown) {
67148
const url = `${creds.altimateUrl}${endpoint}`
68149
const res = await fetch(url, {
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import type { Hooks, PluginInput } from "@opencode-ai/plugin"
2+
3+
export async function AltimateAuthPlugin(_input: PluginInput): Promise<Hooks> {
4+
return {
5+
auth: {
6+
provider: "altimate-backend",
7+
methods: [
8+
{
9+
type: "api",
10+
label: "Connect to Altimate",
11+
},
12+
],
13+
},
14+
}
15+
}

packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@ import { DialogModel } from "./dialog-model"
1313
import { useKeyboard } from "@opentui/solid"
1414
import { Clipboard } from "@tui/util/clipboard"
1515
import { useToast } from "../ui/toast"
16+
// altimate_change start — import AltimateApi for direct credential file write
17+
import { AltimateApi } from "../../../../altimate/api/client"
18+
// altimate_change end
1619

1720
const PROVIDER_PRIORITY: Record<string, number> = {
1821
opencode: 0,
@@ -210,6 +213,9 @@ function ApiMethod(props: ApiMethodProps) {
210213
const sdk = useSDK()
211214
const sync = useSync()
212215
const { theme } = useTheme()
216+
// altimate_change start — altimate-backend: validation error signal
217+
const [validationError, setValidationError] = createSignal<string | null>(null)
218+
// altimate_change end
213219

214220
return (
215221
<DialogPrompt
@@ -239,10 +245,47 @@ function ApiMethod(props: ApiMethodProps) {
239245
</text>
240246
</box>
241247
),
248+
// altimate_change start — altimate-backend credential format description
249+
"altimate-backend": (
250+
<box gap={1}>
251+
<text fg={theme.textMuted}>
252+
Enter your Altimate credentials in this format:
253+
</text>
254+
<text fg={theme.text}>
255+
instance-url::instance-name::api-key
256+
</text>
257+
<text fg={theme.textMuted}>
258+
e.g. https://api.getaltimate.com::mycompany::abc123
259+
</text>
260+
<Show when={validationError()}>
261+
<text fg={theme.error}>{validationError()!}</text>
262+
</Show>
263+
</box>
264+
),
265+
// altimate_change end
242266
}[props.providerID] ?? undefined
243267
}
244268
onConfirm={async (value) => {
245269
if (!value) return
270+
// altimate_change start — altimate-backend: validate then write credentials file directly
271+
if (props.providerID === "altimate-backend") {
272+
const parsed = AltimateApi.parseAltimateKey(value)
273+
if (!parsed) {
274+
setValidationError("Invalid format — use: instance-url::instance-name::api-key")
275+
return
276+
}
277+
const validation = await AltimateApi.validateCredentials(parsed)
278+
if (!validation.ok) {
279+
setValidationError(validation.error)
280+
return
281+
}
282+
await AltimateApi.saveCredentials(parsed)
283+
await sdk.client.instance.dispose()
284+
await sync.bootstrap()
285+
dialog.replace(() => <DialogModel providerID={props.providerID} />)
286+
return
287+
}
288+
// altimate_change end
246289
await sdk.client.auth.set({
247290
providerID: props.providerID,
248291
auth: {

packages/opencode/src/plugin/index.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@ import { gitlabAuthPlugin as GitlabAuthPlugin } from "@gitlab/opencode-gitlab-au
1515
// altimate_change start — snowflake cortex plugin import
1616
import { SnowflakeCortexAuthPlugin } from "../altimate/plugin/snowflake"
1717
// altimate_change end
18+
// altimate_change start — altimate backend auth plugin
19+
import { AltimateAuthPlugin } from "../altimate/plugin/altimate"
20+
// altimate_change end
1821

1922
export namespace Plugin {
2023
const log = Log.create({ service: "plugin" })
@@ -25,8 +28,8 @@ export namespace Plugin {
2528
// GitlabAuthPlugin uses a different version of @opencode-ai/plugin (from npm)
2629
// vs the workspace version, causing a type mismatch on internal HeyApiClient.
2730
// The types are structurally compatible at runtime.
28-
// altimate_change start — snowflake cortex internal plugin
29-
const INTERNAL_PLUGINS: PluginInstance[] = [CodexAuthPlugin, CopilotAuthPlugin, GitlabAuthPlugin as unknown as PluginInstance, SnowflakeCortexAuthPlugin]
31+
// altimate_change start — snowflake cortex and altimate backend internal plugins
32+
const INTERNAL_PLUGINS: PluginInstance[] = [CodexAuthPlugin, CopilotAuthPlugin, GitlabAuthPlugin as unknown as PluginInstance, SnowflakeCortexAuthPlugin, AltimateAuthPlugin]
3033
// altimate_change end
3134

3235
const state = Instance.state(async () => {

packages/opencode/src/provider/provider.ts

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { iife } from "@/util/iife"
1818
import { Global } from "../global"
1919
import path from "path"
2020
import { Filesystem } from "../util/filesystem"
21+
import { AltimateApi } from "../altimate/api/client"
2122

2223
// Direct imports for bundled providers
2324
import { createAmazonBedrock, type AmazonBedrockProviderSettings } from "@ai-sdk/amazon-bedrock"
@@ -181,6 +182,47 @@ export namespace Provider {
181182
options: hasKey ? {} : { apiKey: "public" },
182183
}
183184
},
185+
// altimate_change start — Altimate backend provider: ~/.altimate/altimate.json first, auth store (TUI-configured) as fallback
186+
"altimate-backend": async () => {
187+
// Path 1: ~/.altimate/altimate.json (primary — manual file or env-var substitution, never overwritten)
188+
const isConfigured = await AltimateApi.isConfigured()
189+
if (isConfigured) {
190+
try {
191+
const creds = await AltimateApi.getCredentials()
192+
return {
193+
autoload: true,
194+
options: {
195+
baseURL: `${creds.altimateUrl.replace(/\/+$/, "")}/agents/v1`,
196+
apiKey: creds.altimateApiKey,
197+
headers: {
198+
"x-tenant": creds.altimateInstanceName,
199+
},
200+
},
201+
}
202+
} catch {
203+
return { autoload: false }
204+
}
205+
}
206+
// Path 2: auth store (populated by TUI entry, file not yet written)
207+
const auth = await Auth.get("altimate-backend" as any)
208+
if (auth?.type === "api") {
209+
const parsed = AltimateApi.parseAltimateKey(auth.key)
210+
if (parsed) {
211+
return {
212+
autoload: true,
213+
options: {
214+
baseURL: `${parsed.altimateUrl.replace(/\/+$/, "")}/agents/v1`,
215+
apiKey: parsed.altimateApiKey,
216+
headers: {
217+
"x-tenant": parsed.altimateInstanceName,
218+
},
219+
},
220+
}
221+
}
222+
}
223+
return { autoload: false }
224+
},
225+
// altimate_change end
184226
openai: async () => {
185227
return {
186228
autoload: false,
@@ -973,6 +1015,45 @@ export namespace Provider {
9731015
}
9741016
// altimate_change end
9751017

1018+
// altimate_change start — register altimate-backend as an OpenAI-compatible provider
1019+
if (!database["altimate-backend"]) {
1020+
const backendModels: Record<string, Model> = {
1021+
"altimate-default": {
1022+
id: ModelID.make("altimate-default"),
1023+
providerID: ProviderID.make("altimate-backend"),
1024+
name: "Altimate AI",
1025+
family: "openai",
1026+
api: { id: "altimate-default", url: "", npm: "@ai-sdk/openai-compatible" },
1027+
status: "active",
1028+
headers: {},
1029+
options: {},
1030+
cost: { input: 0, output: 0, cache: { read: 0, write: 0 } },
1031+
limit: { context: 200000, output: 128000 },
1032+
capabilities: {
1033+
temperature: true,
1034+
reasoning: false,
1035+
attachment: false,
1036+
toolcall: true,
1037+
input: { text: true, audio: false, image: true, video: false, pdf: false },
1038+
output: { text: true, audio: false, image: false, video: false, pdf: false },
1039+
interleaved: false,
1040+
},
1041+
release_date: "2025-01-01",
1042+
variants: {},
1043+
},
1044+
}
1045+
database["altimate-backend"] = {
1046+
id: ProviderID.make("altimate-backend"),
1047+
name: "Altimate",
1048+
source: "custom",
1049+
env: [],
1050+
options: {},
1051+
models: backendModels,
1052+
}
1053+
}
1054+
// altimate_change end
1055+
1056+
9761057
function mergeProvider(providerID: ProviderID, provider: Partial<Info>) {
9771058
const existing = providers[providerID]
9781059
if (existing) {

packages/opencode/src/util/filesystem.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,9 @@ export namespace Filesystem {
5555
try {
5656
if (mode) {
5757
await writeFile(p, content, { mode })
58+
// altimate_change start — upstream_fix: writeFile { mode } option does not reliably set permissions; explicit chmod ensures correct mode is applied
59+
await chmod(p, mode)
60+
// altimate_change end
5861
} else {
5962
await writeFile(p, content)
6063
}
@@ -63,6 +66,9 @@ export namespace Filesystem {
6366
await mkdir(dirname(p), { recursive: true })
6467
if (mode) {
6568
await writeFile(p, content, { mode })
69+
// altimate_change start — upstream_fix: writeFile { mode } option does not reliably set permissions; explicit chmod ensures correct mode is applied
70+
await chmod(p, mode)
71+
// altimate_change end
6672
} else {
6773
await writeFile(p, content)
6874
}

0 commit comments

Comments
 (0)