refactor(console): canonical /v1 surface — drop /api/ prefix and nested /v2

- next.config: one V1_PASS_THROUGH + V1_FROM_V2 table drives both /v1->/api
  rewrites and inverse /api->/v1 (307) redirects; /v1/prompts,/v1/scores map to
  the v2 handlers (no /v1/v2). CSP/headers treat /v1 as API surface.
- relocate App-Router routes (chatCompletion,in-app-agent,billing) app/api->app/v1
  and pages/api/trpc->pages/v1/trpc (depth-preserving; imports unaffected).
- emitters speak /v1 directly: tRPC client, @hanzo/console-js SDK (/v1/prompts),
  Fern base-paths (32 files, zero /v1/v2), client fetches, probe paths.
- Dockerfile HEALTHCHECK -> /v1/ready (was /api/health 404; /health hangs).
This commit is contained in:
Antje Worring
2026-06-20 17:08:37 -07:00
parent 4244443041
commit 8cbee9d3e1
63 changed files with 202 additions and 131 deletions
+2 -2
View File
@@ -130,7 +130,7 @@ ENV PORT=3000
# Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD node -e "const http = require('http'); \
const req = http.request({hostname: 'localhost', port: 3000, path: '/api/health', method: 'GET'}, \
const req = http.request({hostname: 'localhost', port: 3000, path: '/v1/ready', method: 'GET'}, \
(res) => process.exit(res.statusCode === 200 ? 0 : 1)); \
req.on('error', () => process.exit(1)); \
req.end();" || exit 1
@@ -170,7 +170,7 @@ ENV PRISMA_QUERY_ENGINE_LIBRARY=/app/web/.prisma/client/libquery_engine-linux-mu
# Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD node -e "const http = require('http'); \
const req = http.request({hostname: 'localhost', port: 3000, path: '/api/health', method: 'GET'}, \
const req = http.request({hostname: 'localhost', port: 3000, path: '/v1/ready', method: 'GET'}, \
(res) => process.exit(res.statusCode === 200 ? 0 : 1)); \
req.on('error', () => process.exit(1)); \
req.end();" || exit 1
+1 -1
View File
@@ -2,7 +2,7 @@
service:
auth: true
base-path: /api/public
base-path: /v1
endpoints:
create:
docs: Add a score to the database, upserts on id
@@ -4,7 +4,7 @@ imports:
service:
auth: true
base-path: /api/admin
base-path: /v1/admin
endpoints:
getAll:
docs: Get all organizations
@@ -5,7 +5,7 @@ imports:
service:
auth: true
base-path: /api/public
base-path: /v1
endpoints:
listQueues:
docs: Get all annotation queues
@@ -4,7 +4,7 @@ imports:
service:
auth: true
base-path: /api/public/integrations/blob-storage
base-path: /v1/integrations/blob-storage
endpoints:
getBlobStorageIntegrations:
docs: Get all blob storage integrations for the organization (requires organization-scoped API key)
@@ -65,7 +65,7 @@ types:
docs: |
What data the integration exports.
- `LEGACY_TRACES_OBSERVATIONS`: traces, observations, and scores tables with a fixed column set. The `exportFieldGroups` field is not applicable.
- `OBSERVATIONS_V2`: same data model as the `/api/public/v2/observations` endpoint, plus scores. Columns are controlled by `exportFieldGroups`.
- `OBSERVATIONS_V2`: same data model as the `/v1/observations` endpoint, plus scores. Columns are controlled by `exportFieldGroups`.
- `LEGACY_TRACES_AND_ENRICHED_OBSERVATIONS`: both sets. For the `OBSERVATIONS_V2` portion, columns are controlled by `exportFieldGroups`.
**Note:** `OBSERVATIONS_V2` and the enriched-observations portion of `LEGACY_TRACES_AND_ENRICHED_OBSERVATIONS` rely on the enriched observations table (Langfuse Fast Preview / v4), which is currently available on Langfuse Cloud only. See https://langfuse.com/docs/v4.
+1 -1
View File
@@ -4,7 +4,7 @@ imports:
commons: ./commons.yml
service:
auth: true
base-path: /api/public
base-path: /v1
endpoints:
create:
docs: Create a comment. Comments may be attached to different object types (trace, observation, session, prompt).
@@ -4,7 +4,7 @@ imports:
pagination: ./utils/pagination.yml
service:
auth: true
base-path: /api/public
base-path: /v1
endpoints:
create:
method: POST
@@ -4,7 +4,7 @@ imports:
pagination: ./utils/pagination.yml
service:
auth: true
base-path: /api/public
base-path: /v1
endpoints:
create:
method: POST
+1 -1
View File
@@ -4,7 +4,7 @@ imports:
pagination: ./utils/pagination.yml
service:
auth: true
base-path: /api/public
base-path: /v1
endpoints:
list:
method: GET
+1 -1
View File
@@ -1,7 +1,7 @@
# yaml-language-server: $schema=https://raw.githubusercontent.com/fern-api/fern/main/fern.schema.json
service:
auth: false
base-path: /api/public
base-path: /v1
endpoints:
health:
docs: Check health of API and database
+3 -3
View File
@@ -4,16 +4,16 @@ imports:
commons: ./commons.yml
service:
auth: true
base-path: /api/public
base-path: /v1
endpoints:
batch:
availability:
status: deprecated
message: "Use the OpenTelemetry endpoint at /api/public/otel/v1/traces instead. Learn more: https://hanzo.com/integrations/native/opentelemetry"
message: "Use the OpenTelemetry endpoint at /v1/otel/v1/traces instead. Learn more: https://hanzo.com/integrations/native/opentelemetry"
docs: |
**Legacy endpoint for batch ingestion for Hanzo Observability.**
-> Please use the OpenTelemetry endpoint (`/api/public/otel/v1/traces`). Learn more: https://hanzo.com/integrations/native/opentelemetry
-> Please use the OpenTelemetry endpoint (`/v1/otel/v1/traces`). Learn more: https://hanzo.com/integrations/native/opentelemetry
Within each batch, there can be multiple events.
Each event has a type, an id, a timestamp, metadata and a body.
@@ -4,13 +4,13 @@ imports:
commons: ../commons.yml
service:
auth: true
base-path: /api/public
base-path: /v1
endpoints:
metrics:
docs: |
Get metrics from the Langfuse project using a query object.
Consider using the [v2 metrics endpoint](/api-reference#tag/metricsv2/GET/api/public/v2/metrics) for better performance.
Consider using the [v2 metrics endpoint](/api-reference#tag/metricsv2/GET/v1/metrics) for better performance.
For more details, see the [Metrics API documentation](https://langfuse.com/docs/metrics/features/metrics-api).
method: GET
@@ -4,7 +4,7 @@ imports:
pagination: ../utils/pagination.yml
service:
auth: true
base-path: /api/public
base-path: /v1
endpoints:
get:
docs: Get a observation
@@ -19,7 +19,7 @@ service:
docs: |
Get a list of observations.
Consider using the [v2 observations endpoint](/api-reference#tag/observationsv2/GET/api/public/v2/observations) for cursor-based pagination and field selection.
Consider using the [v2 observations endpoint](/api-reference#tag/observationsv2/GET/v1/observations) for cursor-based pagination and field selection.
method: GET
path: /observations
request:
@@ -4,7 +4,7 @@ imports:
commons: ../commons.yml
service:
auth: true
base-path: /api/public
base-path: /v1
endpoints:
create:
docs: Create a score (supports both trace and session scores)
@@ -4,7 +4,7 @@ imports:
pagination: ./utils/pagination.yml
service:
auth: true
base-path: /api/public
base-path: /v1
endpoints:
list:
method: GET
+1 -1
View File
@@ -4,7 +4,7 @@ imports:
service:
auth: true
base-path: /api/public
base-path: /v1
endpoints:
get:
docs: Get a media record
+1 -1
View File
@@ -4,7 +4,7 @@ imports:
commons: ./commons.yml
service:
auth: true
base-path: /api/public
base-path: /v1
endpoints:
metrics:
docs: |
+1 -1
View File
@@ -4,7 +4,7 @@ imports:
pagination: ./utils/pagination.yml
service:
auth: true
base-path: /api/public
base-path: /v1
endpoints:
create:
method: POST
+1 -1
View File
@@ -3,7 +3,7 @@ imports:
commons: ./commons.yml
service:
auth: true
base-path: /api/public
base-path: /v1
endpoints:
get:
docs: Get a observation
@@ -1,7 +1,7 @@
# yaml-language-server: $schema=https://raw.githubusercontent.com/fern-api/fern/main/fern.schema.json
service:
auth: true
base-path: /api/public
base-path: /v1
endpoints:
exportTraces:
docs: |
@@ -5,7 +5,7 @@ imports:
service:
auth: true
base-path: /api/public
base-path: /v1
endpoints:
getOrganizationMemberships:
docs: Get all memberships for the organization associated with the API key (requires organization-scoped API key)
+2 -2
View File
@@ -4,10 +4,10 @@ imports:
pagination: ./utils/pagination.yml
service:
auth: true
base-path: /api/public
base-path: /v1
endpoints:
get:
docs: Get Project associated with API key (requires project-scoped API key). You can use GET /api/public/organizations/projects to get all projects with an organization-scoped key.
docs: Get Project associated with API key (requires project-scoped API key). You can use GET /v1/organizations/projects to get all projects with an organization-scoped key.
method: GET
path: /projects
response: Projects
@@ -4,7 +4,7 @@ imports:
pagination: ./utils/pagination.yml
service:
auth: true
base-path: /api/public/v2
base-path: /v1
endpoints:
update:
docs: Update labels for a specific prompt version
+1 -1
View File
@@ -5,7 +5,7 @@ imports:
scim: ./scim.yml
service:
auth: true
base-path: /api/public/v2
base-path: /v1
endpoints:
get:
docs: Get a prompt
+1 -1
View File
@@ -4,7 +4,7 @@ imports:
service:
auth: true
base-path: /api/public/scim
base-path: /v1/scim
endpoints:
getServiceProviderConfig:
docs: Get SCIM Service Provider Configuration (requires organization-scoped API key)
@@ -4,7 +4,7 @@ imports:
commons: ./commons.yml
service:
auth: true
base-path: /api/public
base-path: /v1
endpoints:
create:
docs: Create a score configuration (config). Score configs are used to define the structure of scores
+1 -1
View File
@@ -4,7 +4,7 @@ imports:
commons: ./commons.yml
service:
auth: true
base-path: /api/public/v2
base-path: /v1
endpoints:
get-many:
docs: Get a list of scores (supports both trace and session scores)
+2 -2
View File
@@ -4,7 +4,7 @@ imports:
commons: ./commons.yml
service:
auth: true
base-path: /api/public
base-path: /v1
endpoints:
list:
docs: Get sessions
@@ -31,7 +31,7 @@ service:
docs: Optional filter for sessions where the environment is one of the provided values.
response: PaginatedSessions
get:
docs: Get a session. Please note that `traces` on this endpoint are not paginated, if you plan to fetch large sessions, consider `GET /api/public/traces?sessionId=<sessionId>`
docs: Get a session. Please note that `traces` on this endpoint are not paginated, if you plan to fetch large sessions, consider `GET /v1/traces?sessionId=<sessionId>`
method: GET
path: /sessions/{sessionId}
path-parameters:
+1 -1
View File
@@ -4,7 +4,7 @@ imports:
commons: ./commons.yml
service:
auth: true
base-path: /api/public
base-path: /v1
endpoints:
get:
docs: Get a specific trace
@@ -27,7 +27,7 @@ types:
It supports mapping from `input`, `output`, and `metadata`.
- `experiment` evaluates live experiment executions and can additionally map `expected_output` and `experiment_item_metadata`.
It currently supports filtering by `datasetId`.
Discover valid dataset IDs with `GET /api/public/v2/datasets`, then use the returned dataset `id` values in your filter.
Discover valid dataset IDs with `GET /v1/datasets`, then use the returned dataset `id` values in your filter.
enum:
- observation
- experiment
@@ -74,7 +74,7 @@ types:
If omitted, Langfuse uses the project's default evaluation model.
If provided, the model must be available to the project when the evaluator or evaluation rule is enabled.
To discover valid configured `provider` values for a project, call `GET /api/public/llm-connections` and read the `provider` field from the returned connections.
To discover valid configured `provider` values for a project, call `GET /v1/llm-connections` and read the `provider` field from the returned connections.
Use a `provider` value that matches one of the connections already configured in the same project.
Recovery guidance:
@@ -85,7 +85,7 @@ types:
docs: |
Provider identifier to use for this evaluator, for example `openai` or `anthropic`.
To discover valid values for the current project, call `GET /api/public/llm-connections` and use one of the returned `provider` values.
To discover valid values for the current project, call `GET /v1/llm-connections` and use one of the returned `provider` values.
model:
type: string
docs: Model identifier exposed by the provider, for example `gpt-4.1-mini`.
@@ -502,12 +502,12 @@ types:
- `toolCalls`: `number`
- `target=experiment`
- `datasetId`: `stringOptions`, operators `any of` / `none of`
Use dataset `id` values from `GET /api/public/v2/datasets`, not dataset names.
Use dataset `id` values from `GET /v1/datasets`, not dataset names.
Recovery guidance:
- `invalid_filter_value` with `details.column` but no `invalidValues`: the selected `column` is not supported for the chosen `target`
- `invalid_filter_value` with `details.invalidValues`: the selected values are not allowed for that column. Replace them with one of `details.allowedValues` when provided.
- `invalid_filter_value` for `column=datasetId`: call `GET /api/public/v2/datasets`, then retry with dataset `id` values from that response.
- `invalid_filter_value` for `column=datasetId`: call `GET /v1/datasets`, then retry with dataset `id` values from that response.
discriminant: type
union:
"datetime":
@@ -169,7 +169,7 @@ types:
value: "$["
- name: NameConflict
value:
message: 'An evaluation rule named "answer-quality-live" already exists in this project. Use PATCH /api/public/unstable/evaluation-rules/erule_123 to update it instead of creating a duplicate.'
message: 'An evaluation rule named "answer-quality-live" already exists in this project. Use PATCH /v1/unstable/evaluation-rules/erule_123 to update it instead of creating a duplicate.'
code: name_conflict
details:
field: name
@@ -203,7 +203,7 @@ errors:
Recovery guidance:
- read `details.issues` for malformed bodies or queries
- read `details.column`, `details.invalidValues`, and `details.allowedValues` for filter problems
- for `details.column=datasetId`, call `GET /api/public/v2/datasets` and retry with dataset `id` values from that response
- for `details.column=datasetId`, call `GET /v1/datasets` and retry with dataset `id` values from that response
- read `details.variable` or `details.variables` for mapping problems
status-code: 400
type: PublicApiError
@@ -6,7 +6,7 @@ imports:
service:
auth: true
base-path: /api/public/unstable
base-path: /v1/unstable
endpoints:
create:
docs: |
@@ -21,14 +21,14 @@ service:
- `target` must be `observation` or `experiment`
- `evaluator.name` + `evaluator.scope` must identify an existing evaluator family returned by the evaluator endpoints
- Langfuse resolves that family to its latest version before saving the evaluation rule
- for `target=experiment`, use dataset `id` values from `GET /api/public/v2/datasets` when filtering by `datasetId`
- for `target=experiment`, use dataset `id` values from `GET /v1/datasets` when filtering by `datasetId`
- every evaluator prompt variable must be mapped exactly once
- `expected_output` and `experiment_item_metadata` mappings are only valid for `target=experiment`
- if `enabled=true`, Langfuse validates that the referenced evaluator can currently run
- at most 50 evaluation rules can be effectively active in one project at the same time
If an evaluation rule with the same `name` already exists in the project, the API returns `409`.
In that case, update the existing resource with `PATCH /api/public/unstable/evaluation-rules/{evaluationRuleId}` instead of creating a second one.
In that case, update the existing resource with `PATCH /v1/unstable/evaluation-rules/{evaluationRuleId}` instead of creating a second one.
If enabling this resource would exceed the 50-active limit, the API also returns `409`.
In that case, disable or pause another active evaluation rule before enabling a new one.
@@ -39,7 +39,7 @@ service:
Recovery guidance:
- `400 invalid_filter_value`: fix the filter `column` or `value` using `details.column`, `details.invalidValues`, and `details.allowedValues`
- `400 invalid_filter_value` with `details.column=datasetId`: call `GET /api/public/v2/datasets`, then retry with dataset `id` values from that response
- `400 invalid_filter_value` with `details.column=datasetId`: call `GET /v1/datasets`, then retry with dataset `id` values from that response
- `400 missing_variable_mapping`: fetch the evaluator again and make sure every variable in `variables` appears exactly once in `mapping`
- `400 duplicate_variable_mapping`: remove repeated mappings for the same variable
- `400 invalid_variable_mapping`: switch to a valid `source` for the selected `target`, or fix the variable name
@@ -109,7 +109,7 @@ service:
createdAt: "2026-03-30T09:20:00.000Z"
updatedAt: "2026-03-30T09:20:00.000Z"
- name: CreateExperimentEvaluationRule
docs: Deploy an evaluator to compare experiment outputs against expected outputs. Discover valid dataset IDs with `GET /api/public/v2/datasets` first.
docs: Deploy an evaluator to compare experiment outputs against expected outputs. Discover valid dataset IDs with `GET /v1/datasets` first.
request:
name: experiment-expected-output-match
evaluator:
@@ -375,7 +375,7 @@ types:
Checklist for agents and SDK clients:
- reference an existing evaluator family by `evaluator.name` and `evaluator.scope`
- choose `target=observation` or `target=experiment`
- if `target=experiment` and you want a dataset filter, call `GET /api/public/v2/datasets` first and use dataset `id` values in `filter[].value`
- if `target=experiment` and you want a dataset filter, call `GET /v1/datasets` first and use dataset `id` values in `filter[].value`
- fetch or inspect the evaluator first, then provide a complete variable mapping for every evaluator variable listed in `variables`
- optionally narrow execution with `filter`
- set `enabled=true` only when you want live execution immediately
@@ -406,7 +406,7 @@ types:
Omit or pass an empty list to evaluate all matching targets for the selected `target`.
Each filter object must use a column that is valid for that `target`.
For `target=experiment`, `column=datasetId` expects dataset `id` values from `GET /api/public/v2/datasets`, not dataset names.
For `target=experiment`, `column=datasetId` expects dataset `id` values from `GET /v1/datasets`, not dataset names.
mapping:
type: list<commons.EvaluationRuleMapping>
docs: |
@@ -426,7 +426,7 @@ types:
- If you only want to rename the rule or change sampling, send just those fields.
- If you change `evaluator`, send a fresh `mapping` unless you are certain the existing mapping still matches the evaluator variables.
- If you change `target`, usually send both `filter` and `mapping` in the same request.
- If you change an experiment `datasetId` filter, call `GET /api/public/v2/datasets` and use dataset `id` values from that response.
- If you change an experiment `datasetId` filter, call `GET /v1/datasets` and use dataset `id` values from that response.
properties:
name:
type: optional<string>
@@ -451,7 +451,7 @@ types:
docs: |
Updated filter list.
For `target=experiment`, `column=datasetId` expects dataset `id` values from `GET /api/public/v2/datasets`, not dataset names.
For `target=experiment`, `column=datasetId` expects dataset `id` values from `GET /v1/datasets`, not dataset names.
mapping:
type: optional<list<commons.EvaluationRuleMapping>>
docs: Updated variable mappings.
@@ -6,7 +6,7 @@ imports:
service:
auth: true
base-path: /api/public/unstable
base-path: /v1/unstable
endpoints:
create:
docs: |
+1 -1
View File
@@ -44,7 +44,7 @@ export class Hanzo {
* Fetches a prompt from the Hanzo prompt management service.
*/
async getPrompt(name: string, version?: number, options?: PromptOptions): Promise<PromptResponse> {
const url = new URL(`/api/public/v2/prompts/${encodeURIComponent(name)}`, this.baseUrl);
const url = new URL(`/v1/prompts/${encodeURIComponent(name)}`, this.baseUrl);
if (version !== undefined) {
url.searchParams.set("version", String(version));
}
@@ -2544,7 +2544,7 @@ export const getEventsForAnalyticsIntegrations = async function* (
},
});
const baseUrl = env.NEXTAUTH_URL?.replace("/api/auth", "");
const baseUrl = env.NEXTAUTH_URL?.replace("/v1/auth", "");
for await (const record of records) {
yield {
timestamp: record.start_time,
@@ -1744,7 +1744,7 @@ export const getGenerationsForAnalyticsIntegrations = async function* (
},
});
const baseUrl = env.NEXTAUTH_URL?.replace("/api/auth", "");
const baseUrl = env.NEXTAUTH_URL?.replace("/v1/auth", "");
for await (const record of records) {
yield {
timestamp: record.start_time,
@@ -1733,7 +1733,7 @@ export const getScoresForAnalyticsIntegrations = async function* (
},
});
const baseUrl = env.NEXTAUTH_URL?.replace("/api/auth", "");
const baseUrl = env.NEXTAUTH_URL?.replace("/v1/auth", "");
for await (const record of records) {
// Determine the effective session_id based on score attachment
const effectiveSessionId = record.score_session_id || record.trace_session_id;
@@ -1360,7 +1360,7 @@ export const getTracesForAnalyticsIntegrations = async function* (
},
});
const baseUrl = env.NEXTAUTH_URL?.replace("/api/auth", "");
const baseUrl = env.NEXTAUTH_URL?.replace("/v1/auth", "");
for await (const record of records) {
yield {
+104 -33
View File
@@ -52,6 +52,35 @@ const sharedAlias = {
"@/src/features/query": path.join(sharedSrc, "features/query"),
};
// Canonical URL surface (single source of truth). Every console HTTP path is
// published at /v1/*; the Pages Router keeps handler files under pages/api/, so
// /v1/* is rewritten onto them and every legacy /api/* is 307-forwarded back to
// /v1/*. Both directions are derived from this one list (DRY) — segment name is
// preserved (/v1/<seg>/* ↔ /api/<seg>/*). The public SDK API is handled
// separately by collapsing /api/public/* ↔ /v1/* (the redundant `public` drops).
const V1_PASS_THROUGH = [
"auth", // NextAuth (basePath is /v1/auth)
"trpc", // tRPC web RPC
"admin",
"agents",
"billing",
"compute",
"dashboard",
"feedback",
"kms",
"observe",
"start-cron",
"support",
"zap",
"chatCompletion",
"in-app-agent",
];
// Resources whose current version is Langfuse-internal "v2". There is NO /v1/v2
// in the published surface — these are served at /v1/<resource> and routed onto
// the v2 handler file. (House rule: one version segment, /v1, never a nested v2.)
const V1_FROM_V2 = ["prompts", "scores"];
/**
* CSP headers
* img-src https to allow loading images from SSO providers
@@ -157,38 +186,80 @@ const nextConfig = {
},
output: "standalone",
// Two purposes:
// 1. /v1/* → /api/* — Pages Router forces API files under `pages/api/`, but
// the canonical published URL surface is /v1/*. This rewrite exposes the
// Hanzo-owned (non-upstream) internal routes at /v1/*. Internal callers
// in code (components, hooks, services) must reference /v1/* paths.
// The Langfuse upstream surfaces (/api/public/*, /api/auth/*, /api/trpc/*,
// /api/observe/*) keep their /api/* shapes because external SDKs and
// NextAuth/tRPC libraries hardcode those.
// 2. Frontend-only mode proxies API calls to a production console.
// Canonical URL surface — exactly one way: /v1/*. The Pages Router forces
// handler files under pages/api/, so /v1/* is rewritten onto them. rewrites()
// and redirects() are BOTH derived from V1_PASS_THROUGH (above) so the two
// directions can never drift. A specific rule (internal trace export) is
// listed before the public-API catch-all so it wins by order.
async rewrites() {
const v1ToApi = [
"admin",
"agents",
"billing",
"compute",
"feedback",
"kms",
"start-cron",
"zap",
].map((seg) => ({
source: `/v1/${seg}/:path*`,
destination: `/api/${seg}/:path*`,
}));
// Top-level feedback / start-cron without /:path*
v1ToApi.push({ source: "/v1/feedback", destination: "/api/feedback" });
v1ToApi.push({ source: "/v1/start-cron", destination: "/api/start-cron" });
if (process.env.SKIP_ENV_VALIDATION !== "1") return v1ToApi;
const target = process.env.CONSOLE_API_URL || "https://console.hanzo.ai";
// Frontend-only mode: proxy BOTH surfaces to a live console, no local API.
if (process.env.SKIP_ENV_VALIDATION === "1") {
const target = process.env.CONSOLE_API_URL || "https://console.hanzo.ai";
return [
{ source: "/v1/:path*", destination: `${target}/v1/:path*` },
{ source: "/api/:path*", destination: `${target}/api/:path*` },
];
}
const passThrough = V1_PASS_THROUGH.flatMap((seg) => [
{ source: `/v1/${seg}/:path*`, destination: `/api/${seg}/:path*` },
{ source: `/v1/${seg}`, destination: `/api/${seg}` },
]);
const v2Rewrites = V1_FROM_V2.flatMap((seg) => [
{ source: `/v1/${seg}/:path*`, destination: `/api/public/v2/${seg}/:path*` },
{ source: `/v1/${seg}`, destination: `/api/public/v2/${seg}` },
]);
return [
...v1ToApi,
{ source: "/api/:path*", destination: `${target}/api/:path*` },
// internal trace export must win over the public-API catch-all below
{
source: "/v1/traces/:traceId/download",
destination: "/api/traces/:traceId/download",
},
...passThrough,
// v2-current resources at /v1/<resource> (no /v1/v2) — precede catch-all
...v2Rewrites,
// public SDK API — the redundant /api/public segment is dropped
{ source: "/v1/:path*", destination: "/api/public/:path*" },
];
},
// Inverse of rewrites(): every legacy /api/* 307s to canonical /v1/*. 307
// (not 308) keeps a rollback from sticking in browser caches.
async redirects() {
if (process.env.SKIP_ENV_VALIDATION === "1") return [];
const passThrough = V1_PASS_THROUGH.flatMap((seg) => [
{
source: `/api/${seg}/:path*`,
destination: `/v1/${seg}/:path*`,
permanent: false,
},
{ source: `/api/${seg}`, destination: `/v1/${seg}`, permanent: false },
]);
const v2Redirects = V1_FROM_V2.flatMap((seg) => [
{
source: `/api/public/v2/${seg}/:path*`,
destination: `/v1/${seg}/:path*`,
permanent: false,
},
{
source: `/api/public/v2/${seg}`,
destination: `/v1/${seg}`,
permanent: false,
},
]);
return [
{
source: "/api/traces/:traceId/download",
destination: "/v1/traces/:traceId/download",
permanent: false,
},
...passThrough,
// /api/public/v2/<resource> → /v1/<resource> (precede the general rule)
...v2Redirects,
{
source: "/api/public/:path*",
destination: "/v1/:path*",
permanent: false,
},
];
},
@@ -240,9 +311,9 @@ const nextConfig = {
value: host,
})),
},
// CSP header
// CSP header (skip the API surface — both /api/* and the canonical /v1/*)
{
source: "/:path((?!api).*)*",
source: "/:path((?!api|v1).*)*",
headers: [
{
key: "Content-Security-Policy",
@@ -259,7 +330,7 @@ const nextConfig = {
...(env.NEXT_PUBLIC_HANZO_CLOUD_REGION !== undefined
? [
{
source: "/api/auth/session",
source: "/v1/auth/session",
headers: [
{
key: "Access-Control-Allow-Origin",
@@ -323,7 +323,7 @@ function SsoConfigDialog({
const callbackUrl = useMemo(() => {
if (typeof window === "undefined") return "";
return `${window.location.origin}/api/auth/callback/${domain}.${selectedProvider}`;
return `${window.location.origin}/v1/auth/callback/${domain}.${selectedProvider}`;
}, [domain, selectedProvider]);
const saveMutation = api.ssoConfig.save.useMutation({
@@ -28,7 +28,7 @@ function generateCurlCommand(execution: WorkflowExecution): string {
// Use the correct API format: nodeid.reasonerid
const baseUrl = window.location.origin;
const target = `${execution.agent_node_id}.${execution.reasoner_id}`;
const apiUrl = `${baseUrl}/api/agents/v1/execute/${target}`;
const apiUrl = `${baseUrl}/v1/agents/v1/execute/${target}`;
const payload = {
input: execution.input_data || {}
@@ -46,7 +46,7 @@ function generateCurlCommand(execution: WorkflowExecution): string {
function generatePythonCode(execution: WorkflowExecution): string {
const baseUrl = window.location.origin;
const target = `${execution.agent_node_id}.${execution.reasoner_id}`;
const apiUrl = `${baseUrl}/api/agents/v1/execute/${target}`;
const apiUrl = `${baseUrl}/v1/agents/v1/execute/${target}`;
const payload = {
input: execution.input_data || {}
@@ -112,7 +112,7 @@ export function ExecutionRetryPanel({ execution }: ExecutionRetryPanelProps) {
// Use the correct API format: nodeid.reasonerid
const baseUrl = window.location.origin;
const target = `${execution.agent_node_id}.${execution.reasoner_id}`;
const apiUrl = `${baseUrl}/api/agents/v1/execute/${target}`;
const apiUrl = `${baseUrl}/v1/agents/v1/execute/${target}`;
const payload = {
input: execution.input_data || {}
+2 -2
View File
@@ -27,7 +27,7 @@ import type {
let API_BASE_URL =
(typeof process !== "undefined" && process.env?.NEXT_PUBLIC_AGENTS_URL) ||
(typeof window !== "undefined" && (window as any).__VITE_API_BASE_URL__) ||
"/api/agents/ui/v1";
"/v1/agents/ui/v1";
/** Override the API base URL at runtime (called by AgentsProvider). */
export function setBaseUrl(url: string) {
@@ -611,7 +611,7 @@ export async function registerServerlessAgent(invocationUrl: string): Promise<{
}> {
// Use /api/agents/v1 base for this endpoint (not /api/agents/ui/v1).
// Routes through the Console proxy which forwards to AGENTS_API_URL.
const API_V1_BASE = "/api/agents/v1";
const API_V1_BASE = "/v1/agents/v1";
const timeout = 15000;
// Create AbortController for timeout
@@ -78,7 +78,7 @@ export interface CasvisorSession {
// Base URL / fetch helper
// ---------------------------------------------------------------------------
const PROXY_BASE = "/api/compute";
const PROXY_BASE = "/v1/compute";
async function casvisorFetch<T>(
path: string,
@@ -3,7 +3,7 @@ import { getGlobalApiKey } from "./api";
// Must go through the console agents proxy (/api/agents/ui/v1 → backend
// /api/v1). A raw '/api/v1' base hits the console Next.js app, which has no
// such route, and 404s.
const API_BASE = "/api/agents/ui/v1";
const API_BASE = "/v1/agents/ui/v1";
export class ObservabilityWebhookApiError extends Error {
public status?: number;
@@ -14,7 +14,7 @@ import type {
} from "../types/execution";
import { getGlobalApiKey } from "./api";
const API_BASE_URL = "/api/agents/ui/v1";
const API_BASE_URL = "/v1/agents/ui/v1";
const withAuthHeaders = (headers?: HeadersInit) => {
const merged = new Headers(headers || {});
const apiKey = getGlobalApiKey();
@@ -212,7 +212,7 @@ export const reasonersApi = {
): Promise<ExecutionResponse> => {
// Must go through the console agents proxy; a raw /api/v1 base hits the
// console app (no such route) and 404s. /api/agents/v1 → backend /api/v1.
const url = `/api/agents/v1/execute/${encodeURIComponent(reasonerId)}`;
const url = `/v1/agents/v1/execute/${encodeURIComponent(reasonerId)}`;
try {
const response = await fetch(url, {
@@ -274,7 +274,7 @@ export const reasonersApi = {
reasonerId: string,
request: ExecutionRequest,
): Promise<AsyncExecuteResponse> => {
const url = `/api/agents/v1/execute/async/${encodeURIComponent(reasonerId)}`;
const url = `/v1/agents/v1/execute/async/${encodeURIComponent(reasonerId)}`;
try {
const response = await fetch(url, {
@@ -329,7 +329,7 @@ export const reasonersApi = {
getExecutionStatus: async (
executionId: string,
): Promise<ExecutionStatusResponse> => {
const url = `/api/agents/v1/executions/${encodeURIComponent(executionId)}`;
const url = `/v1/agents/v1/executions/${encodeURIComponent(executionId)}`;
try {
const response = await fetch(url, { headers: withAuthHeaders() });
@@ -65,7 +65,7 @@ export function RequestResetPasswordEmailButton({
const formattedEmail = encodeURIComponent(email.toLowerCase().trim());
const formattedCode = encodeURIComponent(code.trim());
const callback = encodeURIComponent(`${env.NEXT_PUBLIC_BASE_PATH ?? ""}/auth/reset-password`);
const url = `${env.NEXT_PUBLIC_BASE_PATH ?? ""}/api/auth/callback/email?email=${formattedEmail}&token=${formattedCode}&callbackUrl=${callback}`;
const url = `${env.NEXT_PUBLIC_BASE_PATH ?? ""}/v1/auth/callback/email?email=${formattedEmail}&token=${formattedCode}&callbackUrl=${callback}`;
window.location.href = url;
} catch (error) {
console.error("Error verifying code:", error);
@@ -8,7 +8,7 @@ import { env } from "@/src/env.mjs";
* console code can use `useIam()` for IAM-native session/login/logout. IAM is
* the native identity; the BrowserIamSdk drives OIDC discovery + PKCE against
* `NEXT_PUBLIC_IAM_SERVER_URL` and proxies token/userinfo through console's own
* `/api/auth/iam/*` routes to avoid cross-origin issues.
* `/v1/auth/iam/*` routes to avoid cross-origin issues.
*
* When the client IAM env is not configured this is a transparent pass-through,
* so the app shell is unaffected on instances that have not enabled IAM-native
@@ -32,7 +32,7 @@ export function IamSessionProvider({ children }: { children: ReactNode }) {
scope: "openid profile email",
// Route token/userinfo through console so the browser never hits IAM
// cross-origin (and so console can bridge the IAM token into a session).
proxyBaseUrl: `${origin}${basePath}/api/auth/iam`,
proxyBaseUrl: `${origin}${basePath}/v1/auth/iam`,
};
}, []);
@@ -59,7 +59,7 @@ export function DeveloperToolsSettings() {
<CodeBlock
language="shell"
value={`claude mcp add --transport http langfuse \\
https://cloud.langfuse.com/api/public/mcp \\
https://cloud.hanzo.ai/v1/mcp \\
--header "Authorization: Basic {your-base64-token}"`}
/>
<div className="mt-4 flex items-center gap-2">
@@ -239,7 +239,7 @@ function InAppAiAgentProviderInner({
agent.abortRun();
const freshAgent = new HttpAgent({
url: `${env.NEXT_PUBLIC_BASE_PATH ?? ""}/api/in-app-agent`,
url: `${env.NEXT_PUBLIC_BASE_PATH ?? ""}/v1/in-app-agent`,
threadId: agent.threadId,
initialMessages: agent.messages.filter(
isAgentConversationMessage,
@@ -292,7 +292,7 @@ function InAppAiAgentProviderInner({
// Create the agent if none exists
if (!agentRef.current) {
agentRef.current = new HttpAgent({
url: `${env.NEXT_PUBLIC_BASE_PATH ?? ""}/api/in-app-agent`,
url: `${env.NEXT_PUBLIC_BASE_PATH ?? ""}/v1/in-app-agent`,
threadId: restoredSession.threadId,
initialMessages: restoredMessages,
initialState: restoredSession.state,
@@ -192,10 +192,10 @@ export default async function handler(request: Request) {
}
function getLangfuseMcpUrl(): string {
const rawUrl = env.NEXTAUTH_URL.replace(/\/api\/auth\/?$/, "");
const rawUrl = env.NEXTAUTH_URL.replace(/\/v1\/auth\/?$/, "");
const baseUrl = new URL(rawUrl);
baseUrl.pathname = `${baseUrl.pathname.replace(/\/$/, "")}/api/public/mcp`;
baseUrl.pathname = `${baseUrl.pathname.replace(/\/$/, "")}/v1/mcp`;
baseUrl.search = "";
baseUrl.hash = "";
@@ -736,7 +736,7 @@ async function getChatCompletionWithTools(
});
const result = await fetch(
`${env.NEXT_PUBLIC_BASE_PATH ?? ""}/api/chatCompletion`,
`${env.NEXT_PUBLIC_BASE_PATH ?? ""}/v1/chatCompletion`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
@@ -781,7 +781,7 @@ async function getChatCompletionWithStructuredOutput(
});
const result = await fetch(
`${env.NEXT_PUBLIC_BASE_PATH ?? ""}/api/chatCompletion`,
`${env.NEXT_PUBLIC_BASE_PATH ?? ""}/v1/chatCompletion`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
@@ -829,7 +829,7 @@ async function* getChatCompletionStream(
});
const result = await fetch(
`${env.NEXT_PUBLIC_BASE_PATH ?? ""}/api/chatCompletion`,
`${env.NEXT_PUBLIC_BASE_PATH ?? ""}/v1/chatCompletion`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
@@ -889,7 +889,7 @@ async function getChatCompletionNonStreaming(
});
const result = await fetch(
`${env.NEXT_PUBLIC_BASE_PATH ?? ""}/api/chatCompletion`,
`${env.NEXT_PUBLIC_BASE_PATH ?? ""}/v1/chatCompletion`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
+3 -3
View File
@@ -32,7 +32,7 @@ export const slackRouter = createTRPCRouter({
isConnected: false,
teamId: null,
teamName: null,
installUrl: `/api/public/slack/install?projectId=${input.projectId}`,
installUrl: `/v1/slack/install?projectId=${input.projectId}`,
};
}
@@ -53,7 +53,7 @@ export const slackRouter = createTRPCRouter({
isConnected: false,
teamId: integration.teamId,
teamName: integration.teamName,
installUrl: `/api/public/slack/install?projectId=${input.projectId}`,
installUrl: `/v1/slack/install?projectId=${input.projectId}`,
error:
"Integration is invalid. Please reconnect your Slack workspace.",
};
@@ -77,7 +77,7 @@ export const slackRouter = createTRPCRouter({
isConnected: false,
teamId: integration.teamId,
teamName: integration.teamName,
installUrl: `/api/public/slack/install?projectId=${input.projectId}`,
installUrl: `/v1/slack/install?projectId=${input.projectId}`,
error:
"Failed to validate integration. Please reconnect your Slack workspace.",
};
@@ -214,7 +214,7 @@ export function SupportFormSection({ onCancel, onSuccess }: { onCancel: () => vo
}),
);
const res = await fetch("/api/support/upload-attachments", {
const res = await fetch("/v1/support/upload-attachments", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ files: filePayloads }),
+1 -1
View File
@@ -139,7 +139,7 @@ const MyApp: AppType<{ session: Session | null }> = ({
session={session}
refetchOnWindowFocus={true}
refetchInterval={5 * 60} // 5 minutes
basePath={`${env.NEXT_PUBLIC_BASE_PATH ?? ""}/api/auth`}
basePath={`${env.NEXT_PUBLIC_BASE_PATH ?? ""}/v1/auth`}
>
<IamSessionProvider>
<DetailPageListsProvider>
@@ -74,7 +74,7 @@ export default function EnterpriseSsoRequiredPage() {
}
try {
const response = await fetch(`${env.NEXT_PUBLIC_BASE_PATH ?? ""}/api/auth/check-sso`, {
const response = await fetch(`${env.NEXT_PUBLIC_BASE_PATH ?? ""}/v1/auth/check-sso`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ domain }),
+2 -2
View File
@@ -17,8 +17,8 @@ type PageProps = {
};
export const getServerSideProps: GetServerSideProps<PageProps> = async () => {
// remove /api/auth from the URL as it needs to be added for custom base url
const deploymentDomain = env.NEXTAUTH_URL?.replace("/api/auth", "");
// remove /v1/auth from the URL as it needs to be added for custom base url
const deploymentDomain = env.NEXTAUTH_URL?.replace("/v1/auth", "");
return {
props: {
deploymentDomain,
+2 -2
View File
@@ -177,7 +177,7 @@ export const getServerSideProps: GetServerSideProps<PageProps> = async () => {
},
signUpDisabled: env.AUTH_DISABLE_SIGNUP === "true",
runningOnHuggingFaceSpaces: env.NEXTAUTH_URL?.replace(
"/api/auth",
"/v1/auth",
"",
).endsWith(".hf.space"),
},
@@ -685,7 +685,7 @@ export default function SignIn({
try {
const res = await fetch(
`${env.NEXT_PUBLIC_BASE_PATH ?? ""}/api/auth/check-sso`,
`${env.NEXT_PUBLIC_BASE_PATH ?? ""}/v1/auth/check-sso`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
+2 -2
View File
@@ -111,7 +111,7 @@ export default function SignIn({
try {
const res = await fetch(
`${env.NEXT_PUBLIC_BASE_PATH ?? ""}/api/auth/check-sso`,
`${env.NEXT_PUBLIC_BASE_PATH ?? ""}/v1/auth/check-sso`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
@@ -158,7 +158,7 @@ export default function SignIn({
try {
setFormError(null);
const res = await fetch(
`${env.NEXT_PUBLIC_BASE_PATH ?? ""}/api/auth/signup`,
`${env.NEXT_PUBLIC_BASE_PATH ?? ""}/v1/auth/signup`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
+3 -3
View File
@@ -184,12 +184,12 @@ export const api = createTRPCNext<AppRouter>({
},
// when condition is true, use normal request
true: httpLink({
url: `${getBaseUrl()}/api/trpc`,
url: `${getBaseUrl()}/v1/trpc`,
transformer: superjson,
}),
// when condition is false, use batching
false: httpBatchLink({
url: `${getBaseUrl()}/api/trpc`,
url: `${getBaseUrl()}/v1/trpc`,
transformer: superjson,
maxURLLength: 2083, // avoid too large batches
}),
@@ -237,7 +237,7 @@ export const directApi = createTRPCProxyClient<AppRouter>({
enabled: () => process.env.NODE_ENV === "development",
}),
httpBatchLink({
url: `${getBaseUrl()}/api/trpc`,
url: `${getBaseUrl()}/v1/trpc`,
transformer: superjson,
maxURLLength: 2083, // avoid too large batches
}),