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
41 changes: 41 additions & 0 deletions apps/web-console/app/api/openapi.yaml/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
// Serves the generated OpenAPI contract so an integrator can point their own
// tooling at it instead of reading a rendered table.
//
// Deliberately outside /console: middleware.ts redirects unauthenticated
// requests under /console to sign-in, and a spec a code generator cannot fetch
// without a session cookie is not a spec anyone will use.
//
// The file is read from packages/openai-contract/ rather than bundled: it is
// 1.1 MB of YAML and no loader in this app turns that into a module. That
// makes the read a real runtime dependency on the image containing the
// package, which is why both deploy/docker/Dockerfile.web-console and
// deploy/docker/Dockerfile.web-console.prod COPY it in. A missing file answers
// 500 with a plain message rather than an empty 200, because an empty spec
// silently generates an empty client.
//
// Node runtime only. `npm run build:cf` (the retired Workers path) has no
// filesystem and this route would fail there.
import { readFile } from "node:fs/promises";

import { OPENAPI_SPEC_PATH } from "@/lib/api-contract";

export async function GET(): Promise<Response> {
let spec: string;
try {
spec = await readFile(OPENAPI_SPEC_PATH, "utf8");
} catch (error) {
console.error("openapi spec unreadable", { path: OPENAPI_SPEC_PATH, error });
return new Response(
"The OpenAPI specification is not available in this deployment.\n",
{ status: 500, headers: { "content-type": "text/plain; charset=utf-8" } },
);
}

return new Response(spec, {
headers: {
"content-type": "application/yaml; charset=utf-8",
"content-disposition": 'inline; filename="hive-openapi.yaml"',
"cache-control": "public, max-age=300",
},
});
}
337 changes: 337 additions & 0 deletions apps/web-console/app/console/docs/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,337 @@
import { redirect } from "next/navigation";

import {
getAccountProfile,
getCatalogModels,
getViewer,
type CatalogModel,
} from "@/lib/control-plane/client";
import {
API_BASE_URL,
OPENAPI_ROUTE,
type EndpointSection,
buildEndpointSections,
diffSpecAgainstMatrix,
loadSpecOperations,
loadSupportMatrix,
} from "@/lib/api-contract";
import { ConsoleShell } from "@/components/app-shell/console-shell";
import { Badge } from "@/components/ui/badge";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { PageHeader } from "@/components/ui/page-header";

/**
* The console's own API reference.
*
* Every endpoint, status and count below is read from
* `packages/openai-contract/` at request time, never typed in here. That is
* the whole point: a hand written endpoint list is stale the first time a
* route changes, and a docs page that is quietly wrong is worse than no docs
* page. The unsupported and out-of-scope endpoints are listed too, because an
* integrator needs to know what will not work before building against it.
*/

const BADGE_TONE: Record<string, "success" | "accent" | "outline" | "neutral"> = {
supported_now: "success",
planned_for_launch: "accent",
explicitly_unsupported_at_launch: "outline",
out_of_scope: "neutral",
};

function CodeBlock({ children }: { children: string }) {
return (
<pre className="overflow-x-auto rounded-md border border-[var(--color-border)] bg-[var(--color-surface-inset)] px-4 py-3 text-xs leading-relaxed text-[var(--color-ink-2)]">
<code className="font-mono">{children}</code>
</pre>
);
}

export default async function DocsPage() {
const viewer = await getViewer();
if (viewer.user.email_verified === false) {
redirect("/console/settings/profile");
}

const [profile, models] = await Promise.all([
getAccountProfile().catch((): { owner_name: string } => ({ owner_name: "" })),
// The quickstart names a model that actually exists on this deployment.
// If the catalog cannot be read the snippets still have to be runnable, so
// they fall back to the alias seeded by supabase/migrations.
getCatalogModels().catch((): CatalogModel[] => []),
]);

// Prefer one of Hive's own routing aliases: they are the documented entry
// points, and an upstream model id happening to sort first would read as a
// recommendation to bypass them.
const alias =
models.find((model) => model.id.startsWith("hive-"))?.id ??
models[0]?.id ??
"hive-default";
const matrix = loadSupportMatrix();
const specOperations = loadSpecOperations();
const disagreements = diffSpecAgainstMatrix(specOperations, matrix);
const countKind = (kind: string) =>
disagreements.filter((entry) => entry.kind === kind).length;
const missingFromSpec = countKind("missing_from_spec");
const missingFromMatrix = countKind("missing_from_matrix");
const statusMismatch = countKind("status_mismatch");
const sections = buildEndpointSections(matrix);

const curl = `curl ${API_BASE_URL}/chat/completions \\
-H "Authorization: Bearer $HIVE_API_KEY" \\
-H "Content-Type: application/json" \\
-d '{
"model": "${alias}",
"messages": [{"role": "user", "content": "Say hello in one sentence."}]
}'`;

const python = `# pip install openai
import os

from openai import OpenAI

client = OpenAI(
base_url="${API_BASE_URL}",
api_key=os.environ["HIVE_API_KEY"],
)

response = client.chat.completions.create(
model="${alias}",
messages=[{"role": "user", "content": "Say hello in one sentence."}],
)
print(response.choices[0].message.content)`;

return (
<ConsoleShell
workspace={{
id: viewer.current_account.id,
name: viewer.current_account.display_name,
slug: viewer.current_account.slug,
}}
memberships={viewer.memberships}
user={{ email: viewer.user.email, name: profile.owner_name || null }}
active="/console/docs"
topbar={
<span className="font-medium text-[var(--color-ink-2)]">
API documentation
</span>
}
>
<PageHeader
eyebrow="Build"
title="API reference"
description="An OpenAI-compatible gateway. Point any OpenAI SDK at the base URL below and swap the key. Every endpoint on this page, supported or not, is read from the generated contract in this repository."
/>

<div className="flex flex-col gap-8">
<Card>
<CardHeader>
<CardTitle>Quickstart</CardTitle>
<CardDescription>
Create a key under API keys, export it as{" "}
<code className="font-mono">HIVE_API_KEY</code>, then call the
gateway exactly as you would call OpenAI.
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-5">
<dl className="grid gap-3 sm:grid-cols-2">
<div className="flex flex-col gap-1">
<dt className="text-2xs uppercase tracking-[0.14em] text-[var(--color-ink-3)]">
Base URL
</dt>
<dd className="font-mono text-xs text-[var(--color-ink)]">
{API_BASE_URL}
</dd>
</div>
<div className="flex flex-col gap-1">
<dt className="text-2xs uppercase tracking-[0.14em] text-[var(--color-ink-3)]">
Authentication
</dt>
<dd className="font-mono text-xs text-[var(--color-ink)]">
Authorization: Bearer &lt;your key&gt;
</dd>
</div>
</dl>

<div className="flex flex-col gap-2">
<h3 className="text-xs font-semibold text-[var(--color-ink-2)]">curl</h3>
<CodeBlock>{curl}</CodeBlock>
</div>

<div className="flex flex-col gap-2">
<h3 className="text-xs font-semibold text-[var(--color-ink-2)]">
Python, official OpenAI SDK
</h3>
<CodeBlock>{python}</CodeBlock>
</div>

<p className="text-xs text-[var(--color-ink-3)] leading-relaxed">
<span className="font-mono">{alias}</span> is a routing alias, not
a single upstream model. See the model catalog for what each alias
resolves to and what it costs.
</p>
</CardContent>
</Card>

<Card>
<CardHeader>
<CardTitle>Machine-readable spec</CardTitle>
<CardDescription>
The OpenAPI document this page is generated from. Point your own
tooling at it rather than reading this page.
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-3">
<p className="text-xs text-[var(--color-ink-3)] leading-relaxed">
Served unauthenticated at{" "}
<a
className="font-mono text-[var(--color-ink-2)] underline underline-offset-4 hover:text-[var(--color-ink)]"
href={OPENAPI_ROUTE}
>
{OPENAPI_ROUTE}
</a>{" "}
on this console&apos;s own origin · {specOperations.length}{" "}
operations described · support matrix version {matrix.version},
generated {matrix.generated}
</p>
<p className="text-xs text-[var(--color-ink-3)] leading-relaxed">
The generation date is printed because it is the honest measure of
how current this page is. Nothing here is hand maintained, so if
that date is old, the classifications below are that old too.
</p>
</CardContent>
</Card>

{disagreements.length > 0 ? (
<Card>
<CardHeader>
<CardTitle>
Where the spec and the support matrix disagree
</CardTitle>
<CardDescription>
{disagreements.length} operations are described differently by
the two generated files: {missingFromSpec} the matrix
classifies but the spec never describes, {statusMismatch} the
two give different support statuses, {missingFromMatrix}{" "}
the spec declares but the matrix never classified. All of them
are listed rather than one file being silently preferred,
because either side can be the stale one.
</CardDescription>
</CardHeader>
<CardContent>
<details>
<summary className="cursor-pointer text-xs text-[var(--color-ink-2)]">
Show all {disagreements.length}
</summary>
<ul className="mt-3 flex flex-col gap-2">
{disagreements.map((disagreement) => (
<li
key={disagreement.operation}
className="text-xs leading-relaxed text-[var(--color-ink-3)]"
>
<span className="font-mono text-[var(--color-ink-2)]">
{disagreement.operation}
</span>{" "}
— {disagreement.detail}
</li>
))}
</ul>
</details>
</CardContent>
</Card>
) : null}

{sections.map((section) => (
<Card key={section.meta.status}>
<CardHeader>
<div className="flex items-center gap-2">
<CardTitle>{section.meta.label}</CardTitle>
<Badge tone={BADGE_TONE[section.meta.status] ?? "neutral"}>
{section.count}
</Badge>
</div>
<CardDescription>{section.meta.meaning}</CardDescription>
</CardHeader>
<CardContent>
<EndpointFamilies
families={section.families}
collapsed={section.meta.status !== "supported_now"}
count={section.count}
/>
</CardContent>
</Card>
))}
</div>
</ConsoleShell>
);
}

function EndpointFamilies({
families,
collapsed,
count,
}: {
families: EndpointSection["families"];
collapsed: boolean;
count: number;
}) {
const tables = (
<div className="flex flex-col gap-6">
{families.map((family) => (
<div key={family.name} className="flex flex-col gap-2">
<h3 className="text-2xs font-medium uppercase tracking-[0.14em] text-[var(--color-ink-3)]">
{family.name}
</h3>
<div className="overflow-x-auto">
<table className="w-full min-w-[36rem] border-collapse text-xs">
<thead>
<tr className="text-left text-[var(--color-ink-3)]">
<th className="w-16 py-1 font-medium">Method</th>
<th className="py-1 font-medium">Path</th>
<th className="py-1 font-medium">Notes</th>
</tr>
</thead>
<tbody>
{family.endpoints.map((endpoint) => (
<tr
key={`${endpoint.method} ${endpoint.path}`}
className="border-t border-[var(--color-border)] align-top"
>
<td className="py-1.5 pr-3 font-mono text-[var(--color-ink-2)]">
{endpoint.method}
</td>
<td className="py-1.5 pr-3 font-mono text-[var(--color-ink)]">
{endpoint.path}
</td>
<td className="py-1.5 text-[var(--color-ink-3)]">
{endpoint.notes}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
))}
</div>
);

if (!collapsed) {
return tables;
}

return (
<details>
<summary className="cursor-pointer text-xs text-[var(--color-ink-2)]">
Show all {count}
</summary>
<div className="mt-4">{tables}</div>
</details>
);
}
2 changes: 1 addition & 1 deletion apps/web-console/app/console/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -273,7 +273,7 @@ export default async function ConsolePage() {
<div className="mt-10 border-t border-[var(--color-border)] pt-4 text-2xs text-[var(--color-ink-3)]">
Need the API reference?{" "}
<Link
href="https://hivegpt.io"
href="/console/docs"
className="text-[var(--color-ink-2)] underline-offset-4 hover:text-[var(--color-ink)] hover:underline"
>
Read the docs
Expand Down
Loading
Loading