Compare commits
28
Commits
v0.0.0
...
b6767e247c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b6767e247c | ||
|
|
6cf5c3e5a9 | ||
|
|
0aaf4116f1 | ||
|
|
fb1a97e8d0 | ||
|
|
9127076f1b | ||
|
|
3d63df9ba8 | ||
|
|
aef122b854 | ||
|
|
bc26ab9340 | ||
|
|
f06edf6a54 | ||
|
|
7b265b8338 | ||
|
|
0e6ebe7353 | ||
|
|
7ebbf2bd03 | ||
|
|
dac1a5da4b | ||
|
|
e71034c3ef | ||
|
|
31ed81a737 | ||
|
|
4d33b1db9b | ||
|
|
dcbc72b8ae | ||
|
|
f3f71ea90d | ||
|
|
ef87f44f4e | ||
|
|
d3a9d3966c | ||
|
|
a949707aaf | ||
|
|
2aabd4288b | ||
|
|
500eb74577 | ||
|
|
d7362985f9 | ||
|
|
8feee6754b | ||
|
|
c8c656046a | ||
|
|
a8dfd5b2f0 | ||
|
|
fd45c2c0d3 |
@@ -1,85 +0,0 @@
|
||||
# Forgejo Actions CI. Note the path: Forgejo reads .forgejo/workflows/, not
|
||||
# .github/workflows/. The remote for this repo is git.riotpiao.com, so a GitHub
|
||||
# workflow here would never run.
|
||||
#
|
||||
# runs-on: docker matches the only label the cluster runner declares
|
||||
# (talos-runner, labels: [docker]).
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
env:
|
||||
REGISTRY: forgejo.riotpiao.com
|
||||
IMAGE: forgejo.riotpiao.com/rock/api-gateway
|
||||
|
||||
jobs:
|
||||
verify:
|
||||
name: Test, vet, build
|
||||
runs-on: docker
|
||||
container:
|
||||
image: golang:1.25-bookworm
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: go vet
|
||||
run: go vet ./...
|
||||
|
||||
# The race detector needs cgo, so this cannot run with CGO_ENABLED=0.
|
||||
- name: go test -race
|
||||
run: go test ./... -race
|
||||
|
||||
- name: Static build
|
||||
run: CGO_ENABLED=0 go build -trimpath -o gateway ./cmd/gateway
|
||||
|
||||
- name: govulncheck
|
||||
run: |
|
||||
go install golang.org/x/vuln/cmd/govulncheck@latest
|
||||
govulncheck ./...
|
||||
continue-on-error: true
|
||||
|
||||
image:
|
||||
name: Build and push image
|
||||
runs-on: docker
|
||||
needs: verify
|
||||
# Only publish from main. PRs get the verify job and nothing else, so an
|
||||
# untrusted branch can never push a tag the cluster might pull.
|
||||
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
|
||||
container:
|
||||
image: docker:27-cli
|
||||
# The runner's dind sidecar shares the pod network and the mTLS cert
|
||||
# emptyDir, so the daemon is reachable on localhost with the client certs
|
||||
# dind generated at startup.
|
||||
options: --network host
|
||||
env:
|
||||
DOCKER_HOST: tcp://localhost:2376
|
||||
DOCKER_TLS_VERIFY: "1"
|
||||
DOCKER_CERT_PATH: /docker-certs/client
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Registry login
|
||||
run: |
|
||||
echo "${FORGEJO_PAT}" | docker login "${REGISTRY}" \
|
||||
--username rock --password-stdin
|
||||
env:
|
||||
FORGEJO_PAT: ${{ secrets.FORGEJO_RIOTPIAO_PAT }}
|
||||
|
||||
# SHA tags only. 6.1 requires them, and :latest makes an Argo rollout
|
||||
# non-deterministic — the same tag can resolve to different bits.
|
||||
- name: Build
|
||||
run: |
|
||||
docker build \
|
||||
--build-arg "VERSION=${GITHUB_SHA}" \
|
||||
-t "${IMAGE}:${GITHUB_SHA}" \
|
||||
.
|
||||
|
||||
- name: Push
|
||||
run: docker push "${IMAGE}:${GITHUB_SHA}"
|
||||
|
||||
- name: Report digest
|
||||
run: |
|
||||
docker inspect --format='{{index .RepoDigests 0}}' "${IMAGE}:${GITHUB_SHA}"
|
||||
@@ -0,0 +1,87 @@
|
||||
# Forgejo Actions build — push image on main only.
|
||||
# Tag is commit short SHA: unique, immutable, maps to exactly one commit.
|
||||
# No write-back, no git push — ArgoCD Image Updater pulls new builds autonomously.
|
||||
# Enabled by Stage 1 (B, C1).
|
||||
name: Build
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
env:
|
||||
REGISTRY: forgejo.riotpiao.com
|
||||
IMAGE: forgejo.riotpiao.com/rock/api-gateway
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build and push image
|
||||
# golang, not a retired generic "docker" runner -- this repo is Go, and
|
||||
# every runner now carries its own dind sidecar to build/push that
|
||||
# repo's images. `container.image` below overrides the runner's own
|
||||
# default (golang:1.25-bookworm) with docker:27-cli for this job only.
|
||||
runs-on: golang
|
||||
container:
|
||||
image: docker:27-cli
|
||||
# No `options: --network host` here -- act_runner ignores that per-job
|
||||
# override and always decides the job container's network from its own
|
||||
# config.yaml (container.network), which defaults to an isolated
|
||||
# per-job bridge. Confirmed live: with that default, DOCKER_HOST=
|
||||
# tcp://localhost:2376 resolved to the job container itself, not dind,
|
||||
# so every command past `docker login` (which never touches DOCKER_HOST
|
||||
# -- it only talks to the registry) failed with "Cannot connect to the
|
||||
# Docker daemon". host networking is set once, for every job, in the
|
||||
# runner's own Helm chart.
|
||||
#
|
||||
# The mTLS certs dind generates at startup are a separate gap: they
|
||||
# live in an emptyDir mounted into the runner/dind containers, not into
|
||||
# containers a workflow spins up. Job containers get no bind mounts at
|
||||
# all unless the path is in the runner's container.valid_volumes
|
||||
# allowlist (empty by default -- this exact mount was rejected until
|
||||
# the runner's Helm chart added a config.yaml scoping valid_volumes to
|
||||
# exactly this path).
|
||||
volumes:
|
||||
- /docker-certs/client:/docker-certs/client:ro
|
||||
env:
|
||||
DOCKER_HOST: tcp://localhost:2376
|
||||
DOCKER_TLS_VERIFY: "1"
|
||||
DOCKER_CERT_PATH: /docker-certs/client
|
||||
steps:
|
||||
# actions/checkout@v4 is a JS action -- Forgejo Actions execs it with
|
||||
# `node`, which docker:27-cli (Alpine) doesn't ship. Without this the
|
||||
# checkout step fails with "exec: node: executable file not found in
|
||||
# $PATH" before any of the job's own steps run. Verified locally:
|
||||
# `apk add --no-cache nodejs git` in this exact image gets node v22 +
|
||||
# git 2.47, and the checkout action's dist/index.js then actually
|
||||
# executes (confirmed by running it directly) instead of failing on a
|
||||
# missing binary.
|
||||
- name: install node (required by JS-based actions)
|
||||
run: apk add --no-cache nodejs git
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Get short SHA
|
||||
id: sha
|
||||
run: |
|
||||
SHORT_SHA=$(git rev-parse --short HEAD)
|
||||
echo "short_sha=${SHORT_SHA}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Registry login
|
||||
run: |
|
||||
echo "${GITHUB_TOKEN}" | docker login "${REGISTRY}" \
|
||||
--username rock --password-stdin
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build
|
||||
run: |
|
||||
docker build \
|
||||
--build-arg "VERSION=${{ steps.sha.outputs.short_sha }}" \
|
||||
-t "${IMAGE}:${{ steps.sha.outputs.short_sha }}" \
|
||||
.
|
||||
|
||||
- name: Push
|
||||
run: docker push "${IMAGE}:${{ steps.sha.outputs.short_sha }}"
|
||||
|
||||
- name: Report digest
|
||||
run: |
|
||||
docker inspect --format='{{index .RepoDigests 0}}' "${IMAGE}:${{ steps.sha.outputs.short_sha }}"
|
||||
@@ -0,0 +1,56 @@
|
||||
# Forgejo Actions CI — verification only (vet, test, build).
|
||||
# Build and push happens in build.yaml on main push.
|
||||
#
|
||||
# Path is .gitea/workflows/, not .forgejo/workflows/ or .github/workflows/.
|
||||
# Verified live against this instance (Forgejo 1.27.0, forgejo.riotpiao.com)
|
||||
# on 2026-08-21: a .forgejo/workflows/*.yaml file never creates an action_run
|
||||
# row on push, not once, for any repo -- confirmed both from application logs
|
||||
# (silent, no error) and directly in the action_run table. A .gitea/workflows
|
||||
# file with an identical job spec fires immediately. .github/workflows also
|
||||
# gets scanned (that's how the old, dead ubuntu-latest CI on this repo and on
|
||||
# kmsvc-manage both got action_run rows despite matching no runner) -- so
|
||||
# .forgejo/workflows/ specifically appears unsupported on this instance/version,
|
||||
# not workflow detection being off in general.
|
||||
#
|
||||
# runs-on: golang -- the generic "docker" runner was retired in favor of
|
||||
# per-language runners (golang/node/rust), each with its own dind sidecar.
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
verify:
|
||||
name: Test, vet, build
|
||||
runs-on: golang
|
||||
container:
|
||||
image: golang:1.25-bookworm
|
||||
steps:
|
||||
# actions/checkout@v4 is a JS action -- Forgejo Actions execs it with
|
||||
# `node`, which golang:1.25-bookworm doesn't ship. Without this the
|
||||
# checkout step fails with "exec: node: executable file not found in
|
||||
# $PATH" before any of the job's own steps run. Same fix already in use
|
||||
# in kmsvc-manage's ci.yaml; carried over here.
|
||||
- name: install node (required by JS-based actions)
|
||||
run: apt-get update && apt-get install -y --no-install-recommends nodejs ca-certificates git
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: go vet
|
||||
run: go vet ./...
|
||||
|
||||
# The race detector needs cgo, so this cannot run with CGO_ENABLED=0.
|
||||
- name: go test -race
|
||||
run: go test ./... -race
|
||||
|
||||
- name: Static build
|
||||
run: CGO_ENABLED=0 go build -trimpath -o gateway ./cmd/gateway
|
||||
|
||||
- name: govulncheck
|
||||
run: |
|
||||
go install golang.org/x/vuln/cmd/govulncheck@latest
|
||||
govulncheck ./...
|
||||
continue-on-error: true
|
||||
@@ -0,0 +1,934 @@
|
||||
# API Gateway Documentation
|
||||
|
||||
## Overview
|
||||
|
||||
The homelab-frontend gateway is a production-ready reverse proxy for LLM model inference. It routes requests to multiple model upstreams based on configuration, with support for streaming, tool calling, and multiple API formats.
|
||||
|
||||
**Base URL**: `https://api.riotpiao.com`
|
||||
|
||||
**Deployment**: Client → nginx ingress → gateway → model upstreams
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Health Endpoints](#health-endpoints)
|
||||
2. [GET /v1/models](#get-v1models) - List available models
|
||||
3. [POST /v1/chat/completions](#post-v1chat-completions) - Chat with LLM
|
||||
4. [POST /v1/embeddings](#post-v1embeddings) - Generate embeddings
|
||||
5. [POST /v1/rerank](#post-v1rerank) - Rerank documents
|
||||
6. [Error Handling](#error-handling)
|
||||
7. [Examples](#examples)
|
||||
|
||||
---
|
||||
|
||||
## Health Endpoints
|
||||
|
||||
### GET /healthz
|
||||
|
||||
Always returns 200 (liveness probe).
|
||||
|
||||
**Response**:
|
||||
```json
|
||||
{"status":"alive"}
|
||||
```
|
||||
|
||||
**Status Code**: 200
|
||||
|
||||
---
|
||||
|
||||
### GET /readyz
|
||||
|
||||
Returns 200 when the gateway is ready (config loaded, upstreams available).
|
||||
|
||||
**Response**:
|
||||
```json
|
||||
{"status":"ready"}
|
||||
```
|
||||
|
||||
**Status Code**: 200 (ready) or 503 (not ready)
|
||||
|
||||
---
|
||||
|
||||
## GET /v1/models
|
||||
|
||||
List all configured models available for dispatch.
|
||||
|
||||
**Method**: GET
|
||||
|
||||
**Path**: `/v1/models`
|
||||
|
||||
**Authentication**: None required
|
||||
|
||||
**Query Parameters**: None
|
||||
|
||||
**Request Headers**:
|
||||
```
|
||||
Accept: application/json
|
||||
```
|
||||
|
||||
**Response Headers**:
|
||||
```
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
**Response Schema**:
|
||||
```json
|
||||
{
|
||||
"object": "list",
|
||||
"data": [
|
||||
{
|
||||
"id": "model-name",
|
||||
"object": "model",
|
||||
"owned_by": "api.riotpiao.com",
|
||||
"created": 1700000000
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Status Codes**:
|
||||
- `200` - OK
|
||||
|
||||
**Example**:
|
||||
|
||||
```bash
|
||||
curl -s https://api.riotpiao.com/v1/models | jq .
|
||||
```
|
||||
|
||||
**Response Example**:
|
||||
```json
|
||||
{
|
||||
"object": "list",
|
||||
"data": [
|
||||
{
|
||||
"id": "reasoning",
|
||||
"object": "model",
|
||||
"owned_by": "api.riotpiao.com",
|
||||
"created": 1700000000
|
||||
},
|
||||
{
|
||||
"id": "ornith:35b",
|
||||
"object": "model",
|
||||
"owned_by": "api.riotpiao.com",
|
||||
"created": 1700000000
|
||||
},
|
||||
{
|
||||
"id": "qwen2.5:3b-instruct",
|
||||
"object": "model",
|
||||
"owned_by": "api.riotpiao.com",
|
||||
"created": 1700000000
|
||||
},
|
||||
{
|
||||
"id": "nomic-ai/nomic-embed-text-v2-moe",
|
||||
"object": "model",
|
||||
"owned_by": "api.riotpiao.com",
|
||||
"created": 1700000000
|
||||
},
|
||||
{
|
||||
"id": "BAAI/bge-reranker-base",
|
||||
"object": "model",
|
||||
"owned_by": "api.riotpiao.com",
|
||||
"created": 1700000000
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## POST /v1/chat/completions
|
||||
|
||||
Chat with an LLM model. Routes to upstream based on the `model` field in the request body.
|
||||
|
||||
**Method**: POST
|
||||
|
||||
**Path**: `/v1/chat/completions`
|
||||
|
||||
**Authentication**: None required (future: Bearer token)
|
||||
|
||||
**Request Headers**:
|
||||
```
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
**Request Body Schema**:
|
||||
```json
|
||||
{
|
||||
"model": "string (required)",
|
||||
"messages": [
|
||||
{
|
||||
"role": "string (user|assistant|system)",
|
||||
"content": "string|array (required)",
|
||||
"tool_calls": "array (optional, from assistant)"
|
||||
}
|
||||
],
|
||||
"temperature": "number (optional, 0-2)",
|
||||
"top_p": "number (optional, 0-1)",
|
||||
"max_tokens": "integer (optional)",
|
||||
"stream": "boolean (optional, default: false)",
|
||||
"tools": [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "string",
|
||||
"description": "string",
|
||||
"parameters": "object"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Response Schema** (non-streaming):
|
||||
```json
|
||||
{
|
||||
"id": "string",
|
||||
"object": "chat.completion",
|
||||
"created": "integer",
|
||||
"model": "string",
|
||||
"choices": [
|
||||
{
|
||||
"index": "integer",
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"content": "string|null",
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "string",
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "string",
|
||||
"arguments": "string (JSON)"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"finish_reason": "stop|tool_calls|length"
|
||||
}
|
||||
],
|
||||
"usage": {
|
||||
"prompt_tokens": "integer",
|
||||
"completion_tokens": "integer",
|
||||
"total_tokens": "integer"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Response Schema** (streaming):
|
||||
```
|
||||
data: {"id":"...", "object":"chat.completion.chunk", "choices":[...]}
|
||||
data: {"id":"...", "object":"chat.completion.chunk", "choices":[...]}
|
||||
...
|
||||
data: [DONE]
|
||||
```
|
||||
|
||||
**Status Codes**:
|
||||
- `200` - OK
|
||||
- `400` - Bad request (missing/invalid model, invalid JSON, etc.)
|
||||
- `500` - Internal server error (upstream issue)
|
||||
|
||||
**Supported Models**:
|
||||
- `reasoning` - Reasoning model
|
||||
- `ornith:35b` - Ornith 35B model
|
||||
- `qwen2.5:3b-instruct` - Qwen 2.5 3B model
|
||||
|
||||
**Examples**:
|
||||
|
||||
### Basic Chat
|
||||
|
||||
```bash
|
||||
curl -X POST https://api.riotpiao.com/v1/chat/completions \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{
|
||||
"model": "reasoning",
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": "What is the capital of France?"
|
||||
}
|
||||
]
|
||||
}'
|
||||
```
|
||||
|
||||
### Chat with Tool Calling
|
||||
|
||||
```bash
|
||||
curl -X POST https://api.riotpiao.com/v1/chat/completions \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{
|
||||
"model": "reasoning",
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": "What is the weather in San Francisco?"
|
||||
}
|
||||
],
|
||||
"tools": [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "get_weather",
|
||||
"description": "Get the weather for a location",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"location": {
|
||||
"type": "string",
|
||||
"description": "City name"
|
||||
},
|
||||
"unit": {
|
||||
"type": "string",
|
||||
"enum": ["celsius", "fahrenheit"]
|
||||
}
|
||||
},
|
||||
"required": ["location"]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}'
|
||||
```
|
||||
|
||||
### Streaming Chat
|
||||
|
||||
```bash
|
||||
curl -N -X POST https://api.riotpiao.com/v1/chat/completions \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{
|
||||
"model": "reasoning",
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": "Count from 1 to 3"
|
||||
}
|
||||
],
|
||||
"stream": true
|
||||
}'
|
||||
```
|
||||
|
||||
### Multi-turn Conversation with Tool Results
|
||||
|
||||
```bash
|
||||
curl -X POST https://api.riotpiao.com/v1/chat/completions \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{
|
||||
"model": "reasoning",
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": "What is the weather?"
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "call_123",
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "get_weather",
|
||||
"arguments": "{\"location\": \"San Francisco\"}"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "tool",
|
||||
"content": "{\"temperature\": 22, \"condition\": \"sunny\"}"
|
||||
}
|
||||
],
|
||||
"tools": [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "get_weather",
|
||||
"description": "Get weather",
|
||||
"parameters": {}
|
||||
}
|
||||
}
|
||||
]
|
||||
}'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## POST /v1/embeddings
|
||||
|
||||
Generate embeddings for text input.
|
||||
|
||||
**Method**: POST
|
||||
|
||||
**Path**: `/v1/embeddings`
|
||||
|
||||
**Authentication**: None required
|
||||
|
||||
**Request Headers**:
|
||||
```
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
**Request Body Schema**:
|
||||
```json
|
||||
{
|
||||
"model": "string (required)",
|
||||
"input": "string | array of strings (required)",
|
||||
"encoding_format": "float | base64 (optional)"
|
||||
}
|
||||
```
|
||||
|
||||
**Response Schema**:
|
||||
```json
|
||||
{
|
||||
"object": "list",
|
||||
"data": [
|
||||
{
|
||||
"object": "embedding",
|
||||
"embedding": [0.1, 0.2, ...],
|
||||
"index": "integer"
|
||||
}
|
||||
],
|
||||
"model": "string",
|
||||
"usage": {
|
||||
"prompt_tokens": "integer",
|
||||
"total_tokens": "integer"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Status Codes**:
|
||||
- `200` - OK
|
||||
- `400` - Bad request (missing/invalid model, etc.)
|
||||
- `500` - Internal server error
|
||||
|
||||
**Supported Models**:
|
||||
- `nomic-ai/nomic-embed-text-v2-moe` - Embedding model
|
||||
|
||||
**Examples**:
|
||||
|
||||
### Single Input
|
||||
|
||||
```bash
|
||||
curl -X POST https://api.riotpiao.com/v1/embeddings \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{
|
||||
"model": "nomic-ai/nomic-embed-text-v2-moe",
|
||||
"input": "The quick brown fox"
|
||||
}'
|
||||
```
|
||||
|
||||
### Multiple Inputs
|
||||
|
||||
```bash
|
||||
curl -X POST https://api.riotpiao.com/v1/embeddings \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{
|
||||
"model": "nomic-ai/nomic-embed-text-v2-moe",
|
||||
"input": [
|
||||
"Document 1 text",
|
||||
"Document 2 text",
|
||||
"Document 3 text"
|
||||
]
|
||||
}'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## POST /v1/rerank
|
||||
|
||||
Rerank documents based on relevance to a query.
|
||||
|
||||
**Method**: POST
|
||||
|
||||
**Path**: `/v1/rerank`
|
||||
|
||||
**Authentication**: None required
|
||||
|
||||
**Request Headers**:
|
||||
```
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
**Request Body Schema**:
|
||||
```json
|
||||
{
|
||||
"model": "string (required)",
|
||||
"query": "string (required)",
|
||||
"texts": ["string"],
|
||||
"top_k": "integer (optional)",
|
||||
"return_documents": "boolean (optional)"
|
||||
}
|
||||
```
|
||||
|
||||
**Response Schema**:
|
||||
```json
|
||||
{
|
||||
"results": [
|
||||
{
|
||||
"index": "integer",
|
||||
"score": "float (0-1)",
|
||||
"text": "string (optional)"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Status Codes**:
|
||||
- `200` - OK
|
||||
- `400` - Bad request (missing/invalid model, etc.)
|
||||
- `500` - Internal server error
|
||||
|
||||
**Supported Models**:
|
||||
- `BAAI/bge-reranker-base` - BGE reranker model
|
||||
|
||||
**Note**: The gateway rewrites the path from `/v1/rerank` to `/rerank` on the upstream.
|
||||
|
||||
**Examples**:
|
||||
|
||||
### Basic Reranking
|
||||
|
||||
```bash
|
||||
curl -X POST https://api.riotpiao.com/v1/rerank \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{
|
||||
"model": "BAAI/bge-reranker-base",
|
||||
"query": "What is machine learning?",
|
||||
"texts": [
|
||||
"Machine learning is a type of artificial intelligence",
|
||||
"Dogs are animals",
|
||||
"Deep learning is a subset of machine learning",
|
||||
"Python is a programming language"
|
||||
]
|
||||
}'
|
||||
```
|
||||
|
||||
### With Top-K Parameter
|
||||
|
||||
```bash
|
||||
curl -X POST https://api.riotpiao.com/v1/rerank \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{
|
||||
"model": "BAAI/bge-reranker-base",
|
||||
"query": "best practices",
|
||||
"texts": [
|
||||
"Follow code style guidelines",
|
||||
"Write unit tests",
|
||||
"Use meaningful variable names",
|
||||
"Eat healthy food"
|
||||
],
|
||||
"top_k": 2
|
||||
}'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Error Response Format
|
||||
|
||||
The gateway returns RFC 9457 Problem Details for client errors (4xx):
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "https://api.example.com/problems/error-type",
|
||||
"title": "Human-readable error title",
|
||||
"status": 400,
|
||||
"detail": "Detailed explanation of what went wrong",
|
||||
"valid_models": ["model1", "model2"] // Only for model-related errors
|
||||
}
|
||||
```
|
||||
|
||||
### Error Types
|
||||
|
||||
#### Unknown Model Error
|
||||
|
||||
**Status**: `400 Bad Request`
|
||||
|
||||
**Trigger**: Model name not in registry
|
||||
|
||||
**Response**:
|
||||
```json
|
||||
{
|
||||
"type": "https://api.example.com/problems/unknown-model",
|
||||
"title": "Unknown Model",
|
||||
"status": 400,
|
||||
"detail": "Model \"gpt-4\" is not available. See valid_models for available options.",
|
||||
"valid_models": ["reasoning", "ornith:35b", "qwen2.5:3b-instruct", "nomic-ai/nomic-embed-text-v2-moe", "BAAI/bge-reranker-base"]
|
||||
}
|
||||
```
|
||||
|
||||
**Example**:
|
||||
```bash
|
||||
curl -X POST https://api.riotpiao.com/v1/chat/completions \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"model":"gpt-4","messages":[]}'
|
||||
```
|
||||
|
||||
#### Missing Model Field
|
||||
|
||||
**Status**: `400 Bad Request`
|
||||
|
||||
**Trigger**: No `model` field in request body
|
||||
|
||||
**Response**:
|
||||
```json
|
||||
{
|
||||
"type": "https://api.example.com/problems/missing-model",
|
||||
"title": "Missing Model",
|
||||
"status": 400,
|
||||
"detail": "The 'model' field is required and must be a non-empty string",
|
||||
"valid_models": [...]
|
||||
}
|
||||
```
|
||||
|
||||
**Example**:
|
||||
```bash
|
||||
curl -X POST https://api.riotpiao.com/v1/chat/completions \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"messages":[]}'
|
||||
```
|
||||
|
||||
#### Invalid JSON
|
||||
|
||||
**Status**: `400 Bad Request`
|
||||
|
||||
**Trigger**: Request body is not valid JSON
|
||||
|
||||
**Response**:
|
||||
```json
|
||||
{
|
||||
"type": "https://api.example.com/problems/invalid-request-body",
|
||||
"title": "Invalid Request Body",
|
||||
"status": 400,
|
||||
"detail": "request body is not valid JSON"
|
||||
}
|
||||
```
|
||||
|
||||
**Example**:
|
||||
```bash
|
||||
curl -X POST https://api.riotpiao.com/v1/chat/completions \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d 'not json'
|
||||
```
|
||||
|
||||
#### Upstream Error
|
||||
|
||||
**Status**: `5xx` (from upstream)
|
||||
|
||||
**Trigger**: Upstream service error
|
||||
|
||||
**Response**: Forwarded from upstream (unmodified)
|
||||
|
||||
---
|
||||
|
||||
## Examples
|
||||
|
||||
### Test Script
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
|
||||
GATEWAY="https://api.riotpiao.com"
|
||||
|
||||
echo "=== Testing Gateway API ==="
|
||||
echo ""
|
||||
|
||||
# Test 1: Health checks
|
||||
echo "1. Health checks"
|
||||
curl -s "$GATEWAY/healthz" | jq .
|
||||
curl -s "$GATEWAY/readyz" | jq .
|
||||
echo ""
|
||||
|
||||
# Test 2: List models
|
||||
echo "2. List models"
|
||||
curl -s "$GATEWAY/v1/models" | jq '.data[] | .id'
|
||||
echo ""
|
||||
|
||||
# Test 3: Chat with reasoning model
|
||||
echo "3. Chat with reasoning model"
|
||||
curl -s -X POST "$GATEWAY/v1/chat/completions" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{
|
||||
"model": "reasoning",
|
||||
"messages": [{"role": "user", "content": "What is 2+2?"}]
|
||||
}' | jq '.choices[0].message.content'
|
||||
echo ""
|
||||
|
||||
# Test 4: Unknown model (should be 400)
|
||||
echo "4. Unknown model (should be 400)"
|
||||
curl -s -X POST "$GATEWAY/v1/chat/completions" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"model":"gpt-4","messages":[]}' | jq '{status: .status, title: .title}'
|
||||
echo ""
|
||||
|
||||
# Test 5: Embeddings
|
||||
echo "5. Embeddings"
|
||||
curl -s -X POST "$GATEWAY/v1/embeddings" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{
|
||||
"model": "nomic-ai/nomic-embed-text-v2-moe",
|
||||
"input": "hello world"
|
||||
}' | jq '.data | length'
|
||||
echo ""
|
||||
|
||||
# Test 6: Rerank
|
||||
echo "6. Rerank"
|
||||
curl -s -X POST "$GATEWAY/v1/rerank" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{
|
||||
"model": "BAAI/bge-reranker-base",
|
||||
"query": "test",
|
||||
"texts": ["a", "b"]
|
||||
}' | jq '.results | length'
|
||||
echo ""
|
||||
|
||||
# Test 7: Streaming
|
||||
echo "7. Streaming (showing first 5 chunks)"
|
||||
curl -s -N -X POST "$GATEWAY/v1/chat/completions" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{
|
||||
"model": "reasoning",
|
||||
"messages": [{"role": "user", "content": "hi"}],
|
||||
"stream": true
|
||||
}' | head -10
|
||||
echo ""
|
||||
|
||||
echo "=== All tests completed ==="
|
||||
```
|
||||
|
||||
### Python Client Example
|
||||
|
||||
```python
|
||||
import requests
|
||||
import json
|
||||
|
||||
GATEWAY = "https://api.riotpiao.com"
|
||||
|
||||
# Get models
|
||||
response = requests.get(f"{GATEWAY}/v1/models")
|
||||
models = response.json()
|
||||
print(f"Available models: {[m['id'] for m in models['data']]}")
|
||||
|
||||
# Chat completion
|
||||
response = requests.post(
|
||||
f"{GATEWAY}/v1/chat/completions",
|
||||
json={
|
||||
"model": "reasoning",
|
||||
"messages": [
|
||||
{"role": "user", "content": "What is machine learning?"}
|
||||
]
|
||||
}
|
||||
)
|
||||
message = response.json()
|
||||
print(f"Response: {message['choices'][0]['message']['content']}")
|
||||
|
||||
# Chat with tools
|
||||
response = requests.post(
|
||||
f"{GATEWAY}/v1/chat/completions",
|
||||
json={
|
||||
"model": "reasoning",
|
||||
"messages": [
|
||||
{"role": "user", "content": "Get the weather"}
|
||||
],
|
||||
"tools": [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "get_weather",
|
||||
"description": "Get weather",
|
||||
"parameters": {}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
result = response.json()
|
||||
if "tool_calls" in result["choices"][0]["message"]:
|
||||
print(f"Tool calls: {result['choices'][0]['message']['tool_calls']}")
|
||||
|
||||
# Streaming
|
||||
response = requests.post(
|
||||
f"{GATEWAY}/v1/chat/completions",
|
||||
json={
|
||||
"model": "reasoning",
|
||||
"messages": [
|
||||
{"role": "user", "content": "Count to 3"}
|
||||
],
|
||||
"stream": True
|
||||
},
|
||||
stream=True
|
||||
)
|
||||
for line in response.iter_lines():
|
||||
if line:
|
||||
print(line)
|
||||
|
||||
# Embeddings
|
||||
response = requests.post(
|
||||
f"{GATEWAY}/v1/embeddings",
|
||||
json={
|
||||
"model": "nomic-ai/nomic-embed-text-v2-moe",
|
||||
"input": "hello world"
|
||||
}
|
||||
)
|
||||
embeddings = response.json()
|
||||
print(f"Embeddings: {embeddings['data'][0]['embedding'][:5]}")
|
||||
|
||||
# Rerank
|
||||
response = requests.post(
|
||||
f"{GATEWAY}/v1/rerank",
|
||||
json={
|
||||
"model": "BAAI/bge-reranker-base",
|
||||
"query": "ML",
|
||||
"texts": ["machine learning", "python", "deep learning"]
|
||||
}
|
||||
)
|
||||
results = response.json()
|
||||
print(f"Rerank results: {results['results']}")
|
||||
```
|
||||
|
||||
### JavaScript/TypeScript Client Example
|
||||
|
||||
```typescript
|
||||
const GATEWAY = "https://api.riotpiao.com";
|
||||
|
||||
// Get models
|
||||
async function getModels() {
|
||||
const response = await fetch(`${GATEWAY}/v1/models`);
|
||||
const data = await response.json();
|
||||
return data.data.map((m: any) => m.id);
|
||||
}
|
||||
|
||||
// Chat completion
|
||||
async function chat(model: string, message: string) {
|
||||
const response = await fetch(`${GATEWAY}/v1/chat/completions`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
model,
|
||||
messages: [{ role: "user", content: message }],
|
||||
}),
|
||||
});
|
||||
const data = await response.json();
|
||||
return data.choices[0].message.content;
|
||||
}
|
||||
|
||||
// Chat with streaming
|
||||
async function chatStream(model: string, message: string) {
|
||||
const response = await fetch(`${GATEWAY}/v1/chat/completions`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
model,
|
||||
messages: [{ role: "user", content: message }],
|
||||
stream: true,
|
||||
}),
|
||||
});
|
||||
|
||||
const reader = response.body!.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
const chunk = decoder.decode(value);
|
||||
const lines = chunk.split("\n");
|
||||
for (const line of lines) {
|
||||
if (line.startsWith("data: ")) {
|
||||
const data = JSON.parse(line.slice(6));
|
||||
if (data.choices[0].delta?.content) {
|
||||
console.log(data.choices[0].delta.content);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Embeddings
|
||||
async function embed(model: string, input: string[]) {
|
||||
const response = await fetch(`${GATEWAY}/v1/embeddings`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ model, input }),
|
||||
});
|
||||
const data = await response.json();
|
||||
return data.data;
|
||||
}
|
||||
|
||||
// Rerank
|
||||
async function rerank(
|
||||
model: string,
|
||||
query: string,
|
||||
texts: string[]
|
||||
) {
|
||||
const response = await fetch(`${GATEWAY}/v1/rerank`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ model, query, texts }),
|
||||
});
|
||||
const data = await response.json();
|
||||
return data.results;
|
||||
}
|
||||
|
||||
// Usage
|
||||
(async () => {
|
||||
const models = await getModels();
|
||||
console.log("Models:", models);
|
||||
|
||||
const response = await chat("reasoning", "What is AI?");
|
||||
console.log("Response:", response);
|
||||
|
||||
await chatStream("reasoning", "Count to 3");
|
||||
|
||||
const embeddings = await embed("nomic-ai/nomic-embed-text-v2-moe", [
|
||||
"hello",
|
||||
]);
|
||||
console.log("Embeddings:", embeddings);
|
||||
|
||||
const rerankResults = await rerank("BAAI/bge-reranker-base", "ML", [
|
||||
"machine learning",
|
||||
"python",
|
||||
]);
|
||||
console.log("Rerank:", rerankResults);
|
||||
})();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Rate Limiting
|
||||
|
||||
Currently, no rate limiting is enforced. This will be added in Phase 4.
|
||||
|
||||
---
|
||||
|
||||
## Authentication
|
||||
|
||||
Currently, no authentication is enforced. Bearer token support will be added in Phase 3.
|
||||
|
||||
---
|
||||
|
||||
## Timeouts
|
||||
|
||||
Default timeouts per route:
|
||||
- **Connect**: 10s
|
||||
- **Read**: 1h (for streaming)
|
||||
- **Write**: 1h
|
||||
|
||||
These are configured per model upstream.
|
||||
|
||||
---
|
||||
|
||||
## Body Size Limits
|
||||
|
||||
- **Default**: 100MB
|
||||
- **Per-route**: Configurable
|
||||
|
||||
Requests exceeding the limit return `413 Request Entity Too Large`.
|
||||
|
||||
---
|
||||
|
||||
## Support
|
||||
|
||||
For issues or questions:
|
||||
- Check gateway logs: `kubectl -n api logs deployment/homelab-frontend`
|
||||
- Check health: `curl https://api.riotpiao.com/healthz`
|
||||
- Verify config: `curl https://api.riotpiao.com/v1/models`
|
||||
@@ -0,0 +1,560 @@
|
||||
# API Testing Guide
|
||||
|
||||
Quick reference for testing the homelab-frontend gateway API.
|
||||
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
# Set base URL
|
||||
export GATEWAY="https://api.riotpiao.com"
|
||||
|
||||
# Or for local testing
|
||||
export GATEWAY="http://localhost:8080"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Quick Tests (Copy & Paste)
|
||||
|
||||
### 1. Health Checks ✅
|
||||
|
||||
```bash
|
||||
# Liveness
|
||||
curl $GATEWAY/healthz | jq .
|
||||
|
||||
# Readiness
|
||||
curl $GATEWAY/readyz | jq .
|
||||
```
|
||||
|
||||
**Expected**: Both return `{"status":"..."}` with HTTP 200
|
||||
|
||||
---
|
||||
|
||||
### 2. List Models ✅
|
||||
|
||||
```bash
|
||||
curl $GATEWAY/v1/models | jq '.data[] | .id'
|
||||
```
|
||||
|
||||
**Expected Output**:
|
||||
```
|
||||
"reasoning"
|
||||
"ornith:35b"
|
||||
"qwen2.5:3b-instruct"
|
||||
"nomic-ai/nomic-embed-text-v2-moe"
|
||||
"BAAI/bge-reranker-base"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3. Chat - Basic ✅
|
||||
|
||||
```bash
|
||||
curl -X POST $GATEWAY/v1/chat/completions \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{
|
||||
"model": "reasoning",
|
||||
"messages": [
|
||||
{"role": "user", "content": "What is 2+2?"}
|
||||
]
|
||||
}' | jq '.choices[0].message.content'
|
||||
```
|
||||
|
||||
**Expected**: Model responds with an answer
|
||||
|
||||
---
|
||||
|
||||
### 4. Chat - Ornith Model ✅
|
||||
|
||||
```bash
|
||||
curl -X POST $GATEWAY/v1/chat/completions \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{
|
||||
"model": "ornith:35b",
|
||||
"messages": [
|
||||
{"role": "user", "content": "Hello"}
|
||||
]
|
||||
}' | jq '.choices[0].message.content'
|
||||
```
|
||||
|
||||
**Expected**: Routes to ornith model, returns response
|
||||
|
||||
---
|
||||
|
||||
### 5. Chat - Qwen Model ✅
|
||||
|
||||
```bash
|
||||
curl -X POST $GATEWAY/v1/chat/completions \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{
|
||||
"model": "qwen2.5:3b-instruct",
|
||||
"messages": [
|
||||
{"role": "user", "content": "Hi"}
|
||||
]
|
||||
}' | jq '.choices[0].message.content'
|
||||
```
|
||||
|
||||
**Expected**: Routes to qwen model, returns response
|
||||
|
||||
---
|
||||
|
||||
### 6. Chat - Unknown Model (Should Error) ❌→✅
|
||||
|
||||
```bash
|
||||
curl -X POST $GATEWAY/v1/chat/completions \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{
|
||||
"model": "gpt-4-turbo",
|
||||
"messages": []
|
||||
}' | jq '.'
|
||||
```
|
||||
|
||||
**Expected**: HTTP 400 with problem+json:
|
||||
```json
|
||||
{
|
||||
"type": "https://api.example.com/problems/unknown-model",
|
||||
"title": "Unknown Model",
|
||||
"status": 400,
|
||||
"detail": "Model \"gpt-4-turbo\" is not available. See valid_models for available options.",
|
||||
"valid_models": ["reasoning", "ornith:35b", ...]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 7. Chat - Missing Model (Should Error) ❌→✅
|
||||
|
||||
```bash
|
||||
curl -X POST $GATEWAY/v1/chat/completions \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{
|
||||
"messages": [{"role": "user", "content": "test"}]
|
||||
}' | jq '.'
|
||||
```
|
||||
|
||||
**Expected**: HTTP 400 with problem+json (missing model)
|
||||
|
||||
---
|
||||
|
||||
### 8. Chat - Streaming ✅
|
||||
|
||||
```bash
|
||||
curl -N -X POST $GATEWAY/v1/chat/completions \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{
|
||||
"model": "reasoning",
|
||||
"messages": [{"role": "user", "content": "count to 3"}],
|
||||
"stream": true
|
||||
}' | head -20
|
||||
```
|
||||
|
||||
**Expected**:
|
||||
- Multiple `data: {...}` lines (SSE chunks)
|
||||
- Final `data: [DONE]`
|
||||
- Chunks arrive incrementally (observable with `-N` flag)
|
||||
|
||||
---
|
||||
|
||||
### 9. Chat - Tool Calling ✅
|
||||
|
||||
```bash
|
||||
curl -X POST $GATEWAY/v1/chat/completions \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{
|
||||
"model": "reasoning",
|
||||
"messages": [
|
||||
{"role": "user", "content": "What is the weather in SF?"}
|
||||
],
|
||||
"tools": [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "get_weather",
|
||||
"description": "Get weather for a location",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"location": {"type": "string"}
|
||||
},
|
||||
"required": ["location"]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}' | jq '.choices[0].message.tool_calls'
|
||||
```
|
||||
|
||||
**Expected**: Array of tool calls (if model decides to call them), or null (if not)
|
||||
|
||||
---
|
||||
|
||||
### 10. Embeddings ✅
|
||||
|
||||
```bash
|
||||
curl -X POST $GATEWAY/v1/embeddings \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{
|
||||
"model": "nomic-ai/nomic-embed-text-v2-moe",
|
||||
"input": "hello world"
|
||||
}' | jq '.data | length'
|
||||
```
|
||||
|
||||
**Expected**: `1` (one embedding vector)
|
||||
|
||||
---
|
||||
|
||||
### 11. Embeddings - Multiple ✅
|
||||
|
||||
```bash
|
||||
curl -X POST $GATEWAY/v1/embeddings \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{
|
||||
"model": "nomic-ai/nomic-embed-text-v2-moe",
|
||||
"input": ["text 1", "text 2", "text 3"]
|
||||
}' | jq '.data | length'
|
||||
```
|
||||
|
||||
**Expected**: `3` (three embedding vectors)
|
||||
|
||||
---
|
||||
|
||||
### 12. Embeddings - Unknown Model (Should Error) ❌→✅
|
||||
|
||||
```bash
|
||||
curl -X POST $GATEWAY/v1/embeddings \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{
|
||||
"model": "unknown-embed",
|
||||
"input": "test"
|
||||
}' | jq '.status'
|
||||
```
|
||||
|
||||
**Expected**: `400` (client error)
|
||||
|
||||
---
|
||||
|
||||
### 13. Rerank ✅
|
||||
|
||||
```bash
|
||||
curl -X POST $GATEWAY/v1/rerank \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{
|
||||
"model": "BAAI/bge-reranker-base",
|
||||
"query": "machine learning",
|
||||
"texts": [
|
||||
"Machine learning is AI",
|
||||
"Python is a language",
|
||||
"Deep learning is ML"
|
||||
]
|
||||
}' | jq '.results'
|
||||
```
|
||||
|
||||
**Expected**: Array of ranked results with scores:
|
||||
```json
|
||||
[
|
||||
{"index": 0, "score": 0.95},
|
||||
{"index": 2, "score": 0.85},
|
||||
{"index": 1, "score": 0.15}
|
||||
]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 14. Rerank - Unknown Model (Should Error) ❌→✅
|
||||
|
||||
```bash
|
||||
curl -X POST $GATEWAY/v1/rerank \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{
|
||||
"model": "unknown-rerank",
|
||||
"query": "test",
|
||||
"texts": ["a"]
|
||||
}' | jq '.status'
|
||||
```
|
||||
|
||||
**Expected**: `400` (client error)
|
||||
|
||||
---
|
||||
|
||||
### 15. Invalid JSON (Should Error) ❌→✅
|
||||
|
||||
```bash
|
||||
curl -X POST $GATEWAY/v1/chat/completions \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d 'not json' | jq '.title'
|
||||
```
|
||||
|
||||
**Expected**: `"Invalid Request Body"` (HTTP 400)
|
||||
|
||||
---
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
Complete this checklist to verify all endpoints:
|
||||
|
||||
### Health Endpoints
|
||||
- [ ] GET /healthz → 200, `{"status":"alive"}`
|
||||
- [ ] GET /readyz → 200, `{"status":"ready"}`
|
||||
|
||||
### Model Discovery
|
||||
- [ ] GET /v1/models → 200, returns all 5 models
|
||||
- [ ] All advertised models can be called (none 400)
|
||||
|
||||
### Chat Completions
|
||||
- [ ] POST /v1/chat/completions (reasoning) → 200, response
|
||||
- [ ] POST /v1/chat/completions (ornith:35b) → 200, response
|
||||
- [ ] POST /v1/chat/completions (qwen2.5:3b-instruct) → 200, response
|
||||
- [ ] POST /v1/chat/completions (unknown model) → 400, problem+json
|
||||
- [ ] POST /v1/chat/completions (missing model) → 400, problem+json
|
||||
- [ ] POST /v1/chat/completions (invalid JSON) → 400, problem+json
|
||||
- [ ] POST /v1/chat/completions (streaming) → 200, SSE chunks
|
||||
- [ ] POST /v1/chat/completions (with tools) → 200, tool_calls present/absent
|
||||
|
||||
### Embeddings
|
||||
- [ ] POST /v1/embeddings (single input) → 200, embedding
|
||||
- [ ] POST /v1/embeddings (multiple inputs) → 200, embeddings array
|
||||
- [ ] POST /v1/embeddings (unknown model) → 400, problem+json
|
||||
|
||||
### Reranking
|
||||
- [ ] POST /v1/rerank → 200, ranked results
|
||||
- [ ] POST /v1/rerank (unknown model) → 400, problem+json
|
||||
- [ ] Verify path is rewritten to /rerank on upstream
|
||||
|
||||
### Error Handling
|
||||
- [ ] Unknown model lists valid_models
|
||||
- [ ] Error responses are problem+json
|
||||
- [ ] No 5xx for client errors (validation errors)
|
||||
- [ ] Upstream errors pass through
|
||||
|
||||
### Streaming
|
||||
- [ ] Chunks arrive incrementally
|
||||
- [ ] Final `[DONE]` sentinel present
|
||||
- [ ] Works for chat completions
|
||||
|
||||
### Tool Calling
|
||||
- [ ] Tool definitions forward to upstream
|
||||
- [ ] Tool calls in response
|
||||
- [ ] Multi-turn with tool results
|
||||
- [ ] Parallel tool calls
|
||||
- [ ] Complex nested arguments preserved
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### 404 Responses
|
||||
|
||||
**Symptom**: All endpoints return `"not found"`
|
||||
|
||||
**Cause**: ConfigMap with models/routes not deployed
|
||||
|
||||
**Solution**:
|
||||
```bash
|
||||
kubectl -n api create configmap homelab-frontend-config \
|
||||
--from-file=config.yaml=k8s/configmap.yaml
|
||||
kubectl -n api rollout restart deployment/homelab-frontend
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 503 (Not Ready)
|
||||
|
||||
**Symptom**: `/readyz` returns 503
|
||||
|
||||
**Cause**: Configuration not loaded or JWKS fetch failed
|
||||
|
||||
**Solution**:
|
||||
```bash
|
||||
# Check logs
|
||||
kubectl -n api logs deployment/homelab-frontend
|
||||
|
||||
# Check config
|
||||
kubectl -n api get configmap homelab-frontend-config
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Connection Refused
|
||||
|
||||
**Symptom**: `Connection refused` or `Temporary failure in name resolution`
|
||||
|
||||
**Cause**:
|
||||
- Gateway not running
|
||||
- Wrong URL/hostname
|
||||
- Network issue
|
||||
|
||||
**Solution**:
|
||||
```bash
|
||||
# Verify gateway is running
|
||||
kubectl -n api get pods -l app=homelab-frontend
|
||||
|
||||
# Check service
|
||||
kubectl -n api get svc homelab-frontend
|
||||
|
||||
# Verify ingress
|
||||
kubectl -n api get ingress api
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Upstream Connection Errors
|
||||
|
||||
**Symptom**: `502 Bad Gateway` or `connection refused to upstream`
|
||||
|
||||
**Cause**: Model upstream service not reachable
|
||||
|
||||
**Solution**:
|
||||
```bash
|
||||
# Check upstreams are running
|
||||
kubectl -n llm-serving get pods
|
||||
|
||||
# Verify addresses in ConfigMap
|
||||
kubectl -n api get configmap homelab-frontend-config -o yaml
|
||||
|
||||
# Test connectivity from gateway pod
|
||||
kubectl -n api exec deployment/homelab-frontend -- \
|
||||
curl -s reasoning-predictor.llm-serving:80/healthz
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Streaming Doesn't Work
|
||||
|
||||
**Symptom**: Chunks arrive all at once (buffered) instead of incrementally
|
||||
|
||||
**Cause**: nginx buffering or client not using `-N` flag
|
||||
|
||||
**Solution**:
|
||||
```bash
|
||||
# Use -N flag
|
||||
curl -N https://api.riotpiao.com/v1/chat/completions ...
|
||||
|
||||
# Verify nginx has buffering disabled
|
||||
# Should have: proxy-buffering: off in Ingress annotations
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Performance Testing
|
||||
|
||||
### Load Test (Simple)
|
||||
|
||||
```bash
|
||||
# Send 10 requests in parallel
|
||||
for i in {1..10}; do
|
||||
curl -X POST $GATEWAY/v1/chat/completions \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"model":"reasoning","messages":[{"role":"user","content":"Hi"}]}' &
|
||||
done
|
||||
wait
|
||||
|
||||
echo "Completed 10 requests"
|
||||
```
|
||||
|
||||
### Concurrency Test
|
||||
|
||||
```bash
|
||||
# Use Apache Bench (if installed)
|
||||
ab -n 100 -c 10 \
|
||||
-p request.json \
|
||||
-T application/json \
|
||||
$GATEWAY/v1/chat/completions
|
||||
|
||||
# Create request.json:
|
||||
# {"model":"reasoning","messages":[{"role":"user","content":"test"}]}
|
||||
```
|
||||
|
||||
### Latency Test
|
||||
|
||||
```bash
|
||||
# Measure response time
|
||||
curl -w "\nTotal time: %{time_total}s\n" \
|
||||
-X POST $GATEWAY/v1/chat/completions \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{
|
||||
"model": "reasoning",
|
||||
"messages": [{"role": "user", "content": "What is AI?"}]
|
||||
}' > /dev/null
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Integration Testing
|
||||
|
||||
### Test with Python
|
||||
|
||||
```bash
|
||||
pip install requests
|
||||
|
||||
cat > test_api.py << 'EOF'
|
||||
import requests
|
||||
import json
|
||||
|
||||
gateway = "https://api.riotpiao.com"
|
||||
|
||||
# Test health
|
||||
r = requests.get(f"{gateway}/healthz")
|
||||
assert r.status_code == 200
|
||||
print("✓ Health check passed")
|
||||
|
||||
# Test models
|
||||
r = requests.get(f"{gateway}/v1/models")
|
||||
assert r.status_code == 200
|
||||
models = [m['id'] for m in r.json()['data']]
|
||||
print(f"✓ Models: {models}")
|
||||
|
||||
# Test chat
|
||||
r = requests.post(
|
||||
f"{gateway}/v1/chat/completions",
|
||||
json={"model": "reasoning", "messages": [{"role": "user", "content": "Hi"}]}
|
||||
)
|
||||
assert r.status_code == 200
|
||||
print("✓ Chat works")
|
||||
|
||||
# Test unknown model error
|
||||
r = requests.post(
|
||||
f"{gateway}/v1/chat/completions",
|
||||
json={"model": "gpt-4", "messages": []}
|
||||
)
|
||||
assert r.status_code == 400
|
||||
assert "unknown" in r.json()['detail'].lower()
|
||||
print("✓ Unknown model error correct")
|
||||
|
||||
# Test embeddings
|
||||
r = requests.post(
|
||||
f"{gateway}/v1/embeddings",
|
||||
json={"model": "nomic-ai/nomic-embed-text-v2-moe", "input": "test"}
|
||||
)
|
||||
assert r.status_code == 200
|
||||
print("✓ Embeddings work")
|
||||
|
||||
# Test rerank
|
||||
r = requests.post(
|
||||
f"{gateway}/v1/rerank",
|
||||
json={"model": "BAAI/bge-reranker-base", "query": "test", "texts": ["a", "b"]}
|
||||
)
|
||||
assert r.status_code == 200
|
||||
print("✓ Reranking works")
|
||||
|
||||
print("\n✅ All tests passed!")
|
||||
EOF
|
||||
|
||||
python test_api.py
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
| Category | Tests | Expected |
|
||||
|----------|-------|----------|
|
||||
| Health | 2 | ✅ Both 200 |
|
||||
| Models | 1 | ✅ 5 models listed |
|
||||
| Chat | 8 | ✅ 6 success + 2 error |
|
||||
| Embeddings | 3 | ✅ 2 success + 1 error |
|
||||
| Rerank | 2 | ✅ 1 success + 1 error |
|
||||
| Streaming | 1 | ✅ Incremental chunks |
|
||||
| Tools | 1 | ✅ Tool calls present |
|
||||
| **TOTAL** | **18+** | **✅ ALL PASS** |
|
||||
|
||||
Once all tests pass, the gateway is production-ready! 🚀
|
||||
+5
-7
@@ -9,8 +9,9 @@ import (
|
||||
"os/signal"
|
||||
"syscall"
|
||||
|
||||
"github.com/Riotpiaole/homelab-frontend/internal/config"
|
||||
"github.com/Riotpiaole/homelab-frontend/internal/server"
|
||||
"forgejo.riotpiao.com/rock/homelab-frontend/internal/config"
|
||||
"forgejo.riotpiao.com/rock/homelab-frontend/internal/proxy"
|
||||
"forgejo.riotpiao.com/rock/homelab-frontend/internal/server"
|
||||
)
|
||||
|
||||
func main() {
|
||||
@@ -30,11 +31,8 @@ func main() {
|
||||
}
|
||||
}
|
||||
|
||||
// Create a basic handler (will be replaced with real routing later)
|
||||
upstreamHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
fmt.Fprintf(w, "not found")
|
||||
})
|
||||
// Create the reverse proxy handler that routes requests based on configuration
|
||||
upstreamHandler := proxy.New(cfg)
|
||||
|
||||
// Create server with health checker
|
||||
srv := server.New(cfg.ListenAddr, cfg.ShutdownTimeout, nil)
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"go.temporal.io/sdk/client"
|
||||
"forgejo.riotpiao.com/rock/homelab-frontend/internal/workflow"
|
||||
)
|
||||
|
||||
func main() {
|
||||
hostPort := flag.String("host", "temporal.temporal:7233", "Temporal server host:port")
|
||||
namespace := flag.String("namespace", "production", "Temporal namespace")
|
||||
taskQueue := flag.String("queue", "worker-production", "Task queue")
|
||||
workflowType := flag.String("workflow", "HelloWorldWorkflow", "Workflow type")
|
||||
workflowID := flag.String("id", "", "Workflow ID (auto-generated if not set)")
|
||||
flag.Parse()
|
||||
|
||||
// Auto-generate ID
|
||||
if *workflowID == "" {
|
||||
*workflowID = fmt.Sprintf("test-%s-%d", *workflowType, time.Now().Unix())
|
||||
}
|
||||
|
||||
log.Printf("Connecting to Temporal at %s (namespace: %s)", *hostPort, *namespace)
|
||||
|
||||
// Connect to Temporal
|
||||
c, err := client.Dial(client.Options{
|
||||
HostPort: *hostPort,
|
||||
Namespace: *namespace,
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to connect: %v", err)
|
||||
}
|
||||
defer c.Close()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Start workflow
|
||||
log.Printf("Starting %s (ID: %s)", *workflowType, *workflowID)
|
||||
|
||||
var run client.WorkflowRun
|
||||
switch *workflowType {
|
||||
case "HelloWorldWorkflow":
|
||||
run, err = c.ExecuteWorkflow(ctx, client.StartWorkflowOptions{
|
||||
ID: *workflowID,
|
||||
TaskQueue: *taskQueue,
|
||||
}, workflow.HelloWorldWorkflow, "World")
|
||||
|
||||
case "GreeterWorkflow":
|
||||
run, err = c.ExecuteWorkflow(ctx, client.StartWorkflowOptions{
|
||||
ID: *workflowID,
|
||||
TaskQueue: *taskQueue,
|
||||
}, workflow.GreeterWorkflow, "Alice")
|
||||
|
||||
case "ProcessOrderWorkflow":
|
||||
run, err = c.ExecuteWorkflow(ctx, client.StartWorkflowOptions{
|
||||
ID: *workflowID,
|
||||
TaskQueue: *taskQueue,
|
||||
}, workflow.ProcessOrderWorkflow, "ORDER-12345")
|
||||
|
||||
default:
|
||||
log.Fatalf("Unknown workflow type: %s", *workflowType)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to start workflow: %v", err)
|
||||
}
|
||||
|
||||
log.Printf("✓ Workflow submitted")
|
||||
log.Printf(" Run ID: %s", run.GetRunID())
|
||||
log.Printf(" Workflow ID: %s", *workflowID)
|
||||
log.Printf(" Watch at: http://localhost:8080/namespaces/%s/workflows/%s", *namespace, *workflowID)
|
||||
|
||||
// Try to get result
|
||||
ctx2, cancel2 := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel2()
|
||||
|
||||
var result string
|
||||
if err := run.Get(ctx2, &result); err != nil {
|
||||
log.Printf("⏳ Workflow executing (or error): %v", err)
|
||||
} else {
|
||||
log.Printf("✓ Result: %s", result)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"log"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
|
||||
"go.temporal.io/sdk/client"
|
||||
"go.temporal.io/sdk/worker"
|
||||
"forgejo.riotpiao.com/rock/homelab-frontend/internal/workflow"
|
||||
)
|
||||
|
||||
func main() {
|
||||
hostPort := flag.String("host", "temporal.temporal:7233", "Temporal server host:port")
|
||||
namespace := flag.String("namespace", "production", "Temporal namespace")
|
||||
taskQueue := flag.String("queue", "worker-production", "Task queue")
|
||||
flag.Parse()
|
||||
|
||||
// Connect to Temporal server
|
||||
c, err := client.Dial(client.Options{
|
||||
HostPort: *hostPort,
|
||||
Namespace: *namespace,
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to connect to Temporal: %v", err)
|
||||
}
|
||||
defer c.Close()
|
||||
|
||||
log.Printf("Connected to Temporal at %s (namespace: %s)", *hostPort, *namespace)
|
||||
|
||||
// Create worker
|
||||
w := worker.New(c, *taskQueue, worker.Options{})
|
||||
|
||||
// Register workflows
|
||||
w.RegisterWorkflow(workflow.HelloWorldWorkflow)
|
||||
w.RegisterWorkflow(workflow.GreeterWorkflow)
|
||||
w.RegisterWorkflow(workflow.ProcessOrderWorkflow)
|
||||
|
||||
// Register activities
|
||||
w.RegisterActivity(workflow.GreetActivity)
|
||||
w.RegisterActivity(workflow.ValidateOrderActivity)
|
||||
w.RegisterActivity(workflow.ProcessPaymentActivity)
|
||||
w.RegisterActivity(workflow.NotifyCustomerActivity)
|
||||
|
||||
log.Printf("Starting worker on task queue: %s", *taskQueue)
|
||||
|
||||
// Handle graceful shutdown
|
||||
sigChan := make(chan os.Signal, 1)
|
||||
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
|
||||
|
||||
go func() {
|
||||
<-sigChan
|
||||
log.Println("Shutting down worker...")
|
||||
w.Stop()
|
||||
}()
|
||||
|
||||
// Run worker (blocks)
|
||||
if err := w.Run(worker.InterruptCh()); err != nil {
|
||||
log.Fatalf("Worker error: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,34 @@
|
||||
module github.com/Riotpiaole/homelab-frontend
|
||||
module forgejo.riotpiao.com/rock/homelab-frontend
|
||||
|
||||
go 1.25.0
|
||||
go 1.25.4
|
||||
|
||||
require gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
require (
|
||||
go.temporal.io/sdk v1.48.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/facebookgo/clock v0.0.0-20150410010913-600d898af40a // indirect
|
||||
github.com/gogo/protobuf v1.3.2 // indirect
|
||||
github.com/golang/mock v1.6.0 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.2 // indirect
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.22.0 // indirect
|
||||
github.com/nexus-rpc/nexus-proto-annotations v0.1.0 // indirect
|
||||
github.com/nexus-rpc/sdk-go v0.7.0 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/robfig/cron v1.2.0 // indirect
|
||||
github.com/stretchr/objx v0.5.2 // indirect
|
||||
github.com/stretchr/testify v1.10.0 // indirect
|
||||
go.temporal.io/api v1.63.4 // indirect
|
||||
golang.org/x/net v0.55.0 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/sys v0.45.0 // indirect
|
||||
golang.org/x/text v0.37.0 // indirect
|
||||
golang.org/x/time v0.3.0 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 // indirect
|
||||
google.golang.org/grpc v1.82.1 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
)
|
||||
|
||||
@@ -1,3 +1,121 @@
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/facebookgo/clock v0.0.0-20150410010913-600d898af40a h1:yDWHCSQ40h88yih2JAcL6Ls/kVkSE8GFACTGVnMPruw=
|
||||
github.com/facebookgo/clock v0.0.0-20150410010913-600d898af40a/go.mod h1:7Ga40egUymuWXxAe151lTNnCv97MddSOVsjpPPkityA=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
|
||||
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
||||
github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc=
|
||||
github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs=
|
||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.2 h1:sGm2vDRFUrQJO/Veii4h4zG2vvqG6uWNkBHSTqXOZk0=
|
||||
github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.2/go.mod h1:wd1YpapPLivG6nQgbf7ZkG1hhSOXDhhn4MLTknx2aAc=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.22.0 h1:asbCHRVmodnJTuQ3qamDwqVOIjwqUPTYmYuemVOx+Ys=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.22.0/go.mod h1:ggCgvZ2r7uOoQjOyu2Y1NhHmEPPzzuhWgcza5M1Ji1I=
|
||||
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
|
||||
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/nexus-rpc/nexus-proto-annotations v0.1.0 h1:2fELd+9sqUtNu6Fg//pw8YFsxOvp8vZ8hfP0nHhNI80=
|
||||
github.com/nexus-rpc/nexus-proto-annotations v0.1.0/go.mod h1:n3UjF1bPCW8llR8tHvbxJ+27yPWrhpo8w/Yg1IOuY0Y=
|
||||
github.com/nexus-rpc/sdk-go v0.7.0 h1:38NrfY5rLnZAiMMs2ZfCKI/CSDzdfJG+27iAgfA8bUI=
|
||||
github.com/nexus-rpc/sdk-go v0.7.0/go.mod h1:FHdPfVQwRuJFZFTF0Y2GOAxCrbIBNrcPna9slkGKPYk=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/robfig/cron v1.2.0 h1:ZjScXvvxeQ63Dbyxy76Fj3AT3Ut0aKsyd2/tl3DTMuQ=
|
||||
github.com/robfig/cron v1.2.0/go.mod h1:JGuDeoQd7Z6yL4zQhZ3OPEVHB7fL6Ka6skscFHfmt2k=
|
||||
github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M=
|
||||
github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA=
|
||||
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||
go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
|
||||
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
|
||||
go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
|
||||
go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
|
||||
go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
|
||||
go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
|
||||
go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
|
||||
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
|
||||
go.temporal.io/api v1.63.4 h1:p4dVIAP3dJop0MfcyH9QSzjU7+V/ttLDhxFhSRUar58=
|
||||
go.temporal.io/api v1.63.4/go.mod h1:SrlW2JMwVlDP4nRWSNznUFqnSHd+YeMDS1BkYo63HCQ=
|
||||
go.temporal.io/sdk v1.48.0 h1:WDctKDVuh0Z8Nf7euAyqs/EwcPg1JTIIq1Fut8Tq118=
|
||||
go.temporal.io/sdk v1.48.0/go.mod h1:SHv3+fLzD0GGZAwf0xNSvu8UmO1nFgG9WBSYoowApIk=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||
golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
|
||||
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
|
||||
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
|
||||
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
|
||||
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
|
||||
golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4=
|
||||
golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
||||
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||
golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
|
||||
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478 h1:yQugLulqltosq0B/f8l4w9VryjV+N/5gcW0jQ3N8Qec=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478/go.mod h1:C6ADNqOxbgdUUeRTU+LCHDPB9ttAMCTff6auwCVa4uc=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 h1:RmoJA1ujG+/lRGNfUnOMfhCy5EipVMyvUE+KNbPbTlw=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||
google.golang.org/grpc v1.82.1 h1:NnAxzGRA0677vCa4BUkOAnO5+FfQqVl9iUXeD0IqcGE=
|
||||
google.golang.org/grpc v1.82.1/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
||||
@@ -3,7 +3,7 @@ package config_test
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/Riotpiaole/homelab-frontend/internal/config"
|
||||
"forgejo.riotpiao.com/rock/homelab-frontend/internal/config"
|
||||
)
|
||||
|
||||
// TestLoadRoutesMissingAuthRequired tests that the auth-required flag is not a silent default:
|
||||
|
||||
@@ -4,7 +4,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Riotpiaole/homelab-frontend/internal/config"
|
||||
"forgejo.riotpiao.com/rock/homelab-frontend/internal/config"
|
||||
)
|
||||
|
||||
// TestLoadRoutesValidConfig tests that a valid configuration loads correctly.
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Riotpiaole/homelab-frontend/internal/config"
|
||||
"forgejo.riotpiao.com/rock/homelab-frontend/internal/config"
|
||||
)
|
||||
|
||||
// TestLoadIntegration tests the full Load function with CONFIG_PATH env var
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Riotpiaole/homelab-frontend/internal/config"
|
||||
"forgejo.riotpiao.com/rock/homelab-frontend/internal/config"
|
||||
)
|
||||
|
||||
// TestBodyBasedDispatch verifies that /v1/chat/completions routes based on model field.
|
||||
@@ -76,7 +76,10 @@ func TestBodyBasedDispatch(t *testing.T) {
|
||||
reasoningCalled = false
|
||||
ornithCalled = false
|
||||
requestBody := `{"model":"reasoning","messages":[{"role":"user","content":"hi"}]}`
|
||||
resp, _ := http.Post(server.URL+"/v1/chat/completions", "application/json", strings.NewReader(requestBody))
|
||||
resp, err := http.Post(server.URL+"/v1/chat/completions", "application/json", strings.NewReader(requestBody))
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
if !reasoningCalled {
|
||||
@@ -151,7 +154,10 @@ func TestBodyPreservedUnmodified(t *testing.T) {
|
||||
|
||||
// Send a request with specific body content
|
||||
originalBody := `{"model":"reasoning","stream":true,"messages":[{"role":"user","content":"hello world"}],"temperature":0.7}`
|
||||
resp, _ := http.Post(server.URL+"/v1/chat/completions", "application/json", strings.NewReader(originalBody))
|
||||
resp, err := http.Post(server.URL+"/v1/chat/completions", "application/json", strings.NewReader(originalBody))
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
if string(receivedBody) != originalBody {
|
||||
@@ -223,7 +229,7 @@ func TestStreamingUnbuffered(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestUnknownModelReject verifies that unknown models are rejected.
|
||||
// TestUnknownModelReject verifies that unknown models are rejected with 400 and problem+json.
|
||||
func TestUnknownModelReject(t *testing.T) {
|
||||
upstreamServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
@@ -249,15 +255,35 @@ func TestUnknownModelReject(t *testing.T) {
|
||||
defer server.Close()
|
||||
|
||||
requestBody := `{"model":"unknown-model","messages":[]}`
|
||||
resp, _ := http.Post(server.URL+"/v1/chat/completions", "application/json", strings.NewReader(requestBody))
|
||||
resp.Body.Close()
|
||||
resp, err := http.Post(server.URL+"/v1/chat/completions", "application/json", strings.NewReader(requestBody))
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusNotFound {
|
||||
t.Errorf("expected 404 for unknown model, got %d", resp.StatusCode)
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Errorf("expected 400 for unknown model, got %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// Verify problem+json content type
|
||||
ct := resp.Header.Get("Content-Type")
|
||||
if !strings.Contains(ct, "application/problem+json") {
|
||||
t.Errorf("expected content-type application/problem+json, got %s", ct)
|
||||
}
|
||||
|
||||
// Verify response is valid JSON
|
||||
var prob map[string]interface{}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&prob); err != nil {
|
||||
t.Errorf("response is not valid JSON: %v", err)
|
||||
}
|
||||
|
||||
// Verify valid_models is included
|
||||
if prob["valid_models"] == nil {
|
||||
t.Errorf("expected valid_models in problem detail")
|
||||
}
|
||||
}
|
||||
|
||||
// TestMissingModelField verifies that missing model field is rejected.
|
||||
// TestMissingModelField verifies that missing model field is rejected with 400 and problem+json.
|
||||
func TestMissingModelField(t *testing.T) {
|
||||
upstreamServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
@@ -283,11 +309,26 @@ func TestMissingModelField(t *testing.T) {
|
||||
defer server.Close()
|
||||
|
||||
requestBody := `{"messages":[]}`
|
||||
resp, _ := http.Post(server.URL+"/v1/chat/completions", "application/json", strings.NewReader(requestBody))
|
||||
resp.Body.Close()
|
||||
resp, err := http.Post(server.URL+"/v1/chat/completions", "application/json", strings.NewReader(requestBody))
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusNotFound {
|
||||
t.Errorf("expected 404 for missing model, got %d", resp.StatusCode)
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Errorf("expected 400 for missing model, got %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// Verify problem+json content type
|
||||
ct := resp.Header.Get("Content-Type")
|
||||
if !strings.Contains(ct, "application/problem+json") {
|
||||
t.Errorf("expected content-type application/problem+json, got %s", ct)
|
||||
}
|
||||
|
||||
// Verify response is valid JSON
|
||||
var prob map[string]interface{}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&prob); err != nil {
|
||||
t.Errorf("response is not valid JSON: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Riotpiaole/homelab-frontend/internal/config"
|
||||
"forgejo.riotpiao.com/rock/homelab-frontend/internal/config"
|
||||
)
|
||||
|
||||
// TestBodySizeCapExact verifies that a body exactly at the cap is accepted.
|
||||
@@ -323,7 +323,10 @@ func TestBodySizeCapRejectionLogged(t *testing.T) {
|
||||
|
||||
// Send an oversized body
|
||||
body := strings.Repeat("a", int(maxBodySize)+1)
|
||||
resp, _ := http.Post(server.URL+"/test", "text/plain", strings.NewReader(body))
|
||||
resp, err := http.Post(server.URL+"/test", "text/plain", strings.NewReader(body))
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
// Verify rejection status
|
||||
|
||||
@@ -0,0 +1,420 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"forgejo.riotpiao.com/rock/homelab-frontend/internal/config"
|
||||
)
|
||||
|
||||
// TestEmbeddingsPassthroughNoRewrite verifies /v1/embeddings is not rewritten
|
||||
func TestEmbeddingsPassthroughNoRewrite(t *testing.T) {
|
||||
embeddingsCalled := false
|
||||
upstreamPath := ""
|
||||
|
||||
upstreamServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
embeddingsCalled = true
|
||||
upstreamPath = r.URL.Path
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
fmt.Fprint(w, `{"object":"list","data":[{"object":"embedding","index":0,"embedding":[0.1,0.2]}]}`)
|
||||
}))
|
||||
defer upstreamServer.Close()
|
||||
|
||||
upstreamAddr := strings.TrimPrefix(upstreamServer.URL, "http://")
|
||||
|
||||
cfg := &config.Config{
|
||||
Models: map[string]*config.ModelUpstream{
|
||||
"nomic-ai/nomic-embed-text-v2-moe": {
|
||||
Name: "nomic-ai/nomic-embed-text-v2-moe",
|
||||
Address: upstreamAddr,
|
||||
},
|
||||
},
|
||||
Routes: make(map[string]*config.Route),
|
||||
}
|
||||
|
||||
handler := New(cfg)
|
||||
defer handler.Close()
|
||||
|
||||
server := httptest.NewServer(handler)
|
||||
defer server.Close()
|
||||
|
||||
resp, err := http.Post(
|
||||
server.URL+"/v1/embeddings",
|
||||
"application/json",
|
||||
bytes.NewReader([]byte(`{"model":"nomic-ai/nomic-embed-text-v2-moe","input":"hello"}`)),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if !embeddingsCalled {
|
||||
t.Errorf("upstream embeddings service was not called")
|
||||
}
|
||||
|
||||
// Verify path is NOT rewritten (should stay /v1/embeddings)
|
||||
if upstreamPath != "/v1/embeddings" {
|
||||
t.Errorf("expected upstream path /v1/embeddings, got %s", upstreamPath)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEmbeddingsResponsePassthrough verifies response body is unmodified
|
||||
func TestEmbeddingsResponsePassthrough(t *testing.T) {
|
||||
expectedResponse := `{"object":"list","data":[{"object":"embedding","index":0,"embedding":[0.1,0.2,0.3]}]}`
|
||||
|
||||
upstreamServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
fmt.Fprint(w, expectedResponse)
|
||||
}))
|
||||
defer upstreamServer.Close()
|
||||
|
||||
upstreamAddr := strings.TrimPrefix(upstreamServer.URL, "http://")
|
||||
|
||||
cfg := &config.Config{
|
||||
Models: map[string]*config.ModelUpstream{
|
||||
"nomic-ai/nomic-embed-text-v2-moe": {
|
||||
Name: "nomic-ai/nomic-embed-text-v2-moe",
|
||||
Address: upstreamAddr,
|
||||
},
|
||||
},
|
||||
Routes: make(map[string]*config.Route),
|
||||
}
|
||||
|
||||
handler := New(cfg)
|
||||
defer handler.Close()
|
||||
|
||||
server := httptest.NewServer(handler)
|
||||
defer server.Close()
|
||||
|
||||
resp, err := http.Post(
|
||||
server.URL+"/v1/embeddings",
|
||||
"application/json",
|
||||
bytes.NewReader([]byte(`{"model":"nomic-ai/nomic-embed-text-v2-moe","input":"test"}`)),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
|
||||
if string(body) != expectedResponse {
|
||||
t.Errorf("response was modified. Expected:\n%s\n\nGot:\n%s", expectedResponse, string(body))
|
||||
}
|
||||
}
|
||||
|
||||
// TestRerankPathRewrite verifies /v1/rerank is rewritten to /rerank
|
||||
func TestRerankPathRewrite(t *testing.T) {
|
||||
upstreamPath := ""
|
||||
|
||||
upstreamServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
upstreamPath = r.URL.Path
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
fmt.Fprint(w, `{"results":[{"index":0,"score":0.9}]}`)
|
||||
}))
|
||||
defer upstreamServer.Close()
|
||||
|
||||
upstreamAddr := strings.TrimPrefix(upstreamServer.URL, "http://")
|
||||
|
||||
cfg := &config.Config{
|
||||
Models: map[string]*config.ModelUpstream{
|
||||
"BAAI/bge-reranker-base": {
|
||||
Name: "BAAI/bge-reranker-base",
|
||||
Address: upstreamAddr,
|
||||
},
|
||||
},
|
||||
Routes: make(map[string]*config.Route),
|
||||
}
|
||||
|
||||
handler := New(cfg)
|
||||
defer handler.Close()
|
||||
|
||||
server := httptest.NewServer(handler)
|
||||
defer server.Close()
|
||||
|
||||
resp, err := http.Post(
|
||||
server.URL+"/v1/rerank",
|
||||
"application/json",
|
||||
bytes.NewReader([]byte(`{"model":"BAAI/bge-reranker-base","query":"test","texts":["a","b"]}`)),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Verify path IS rewritten to /rerank
|
||||
if upstreamPath != "/rerank" {
|
||||
t.Errorf("expected upstream path /rerank, got %s", upstreamPath)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRerankResponsePassthrough verifies response is unmodified
|
||||
func TestRerankResponsePassthrough(t *testing.T) {
|
||||
expectedResponse := `{"results":[{"index":0,"score":0.95},{"index":1,"score":0.85}]}`
|
||||
|
||||
upstreamServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
fmt.Fprint(w, expectedResponse)
|
||||
}))
|
||||
defer upstreamServer.Close()
|
||||
|
||||
upstreamAddr := strings.TrimPrefix(upstreamServer.URL, "http://")
|
||||
|
||||
cfg := &config.Config{
|
||||
Models: map[string]*config.ModelUpstream{
|
||||
"BAAI/bge-reranker-base": {
|
||||
Name: "BAAI/bge-reranker-base",
|
||||
Address: upstreamAddr,
|
||||
},
|
||||
},
|
||||
Routes: make(map[string]*config.Route),
|
||||
}
|
||||
|
||||
handler := New(cfg)
|
||||
defer handler.Close()
|
||||
|
||||
server := httptest.NewServer(handler)
|
||||
defer server.Close()
|
||||
|
||||
resp, err := http.Post(
|
||||
server.URL+"/v1/rerank",
|
||||
"application/json",
|
||||
bytes.NewReader([]byte(`{"model":"BAAI/bge-reranker-base","query":"q","texts":["a"]}`)),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
|
||||
if string(body) != expectedResponse {
|
||||
t.Errorf("response was modified")
|
||||
}
|
||||
}
|
||||
|
||||
// TestEmbeddingsUnknownModel returns error for unknown model
|
||||
func TestEmbeddingsUnknownModel(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
Models: map[string]*config.ModelUpstream{
|
||||
"nomic-ai/nomic-embed-text-v2-moe": {
|
||||
Name: "nomic-ai/nomic-embed-text-v2-moe",
|
||||
Address: "localhost:9000",
|
||||
},
|
||||
},
|
||||
Routes: make(map[string]*config.Route),
|
||||
}
|
||||
|
||||
handler := New(cfg)
|
||||
defer handler.Close()
|
||||
|
||||
server := httptest.NewServer(handler)
|
||||
defer server.Close()
|
||||
|
||||
resp, err := http.Post(
|
||||
server.URL+"/v1/embeddings",
|
||||
"application/json",
|
||||
bytes.NewReader([]byte(`{"model":"unknown-embeddings","input":"test"}`)),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Errorf("expected 400 for unknown embeddings model, got %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
ct := resp.Header.Get("Content-Type")
|
||||
if !strings.Contains(ct, "application/problem+json") {
|
||||
t.Errorf("expected problem+json for unknown model")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRerankerUnknownModel returns error for unknown model
|
||||
func TestRerankerUnknownModel(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
Models: map[string]*config.ModelUpstream{
|
||||
"BAAI/bge-reranker-base": {
|
||||
Name: "BAAI/bge-reranker-base",
|
||||
Address: "localhost:9000",
|
||||
},
|
||||
},
|
||||
Routes: make(map[string]*config.Route),
|
||||
}
|
||||
|
||||
handler := New(cfg)
|
||||
defer handler.Close()
|
||||
|
||||
server := httptest.NewServer(handler)
|
||||
defer server.Close()
|
||||
|
||||
resp, err := http.Post(
|
||||
server.URL+"/v1/rerank",
|
||||
"application/json",
|
||||
bytes.NewReader([]byte(`{"model":"unknown-reranker","query":"q","texts":["a"]}`)),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Errorf("expected 400 for unknown reranker model, got %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEmbeddingsBodyForwarded verifies body is byte-identical to upstream
|
||||
func TestEmbeddingsBodyForwarded(t *testing.T) {
|
||||
receivedBody := ""
|
||||
|
||||
upstreamServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
receivedBody = string(body)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
fmt.Fprint(w, `{"object":"list","data":[]}`)
|
||||
}))
|
||||
defer upstreamServer.Close()
|
||||
|
||||
upstreamAddr := strings.TrimPrefix(upstreamServer.URL, "http://")
|
||||
|
||||
cfg := &config.Config{
|
||||
Models: map[string]*config.ModelUpstream{
|
||||
"nomic-ai/nomic-embed-text-v2-moe": {
|
||||
Name: "nomic-ai/nomic-embed-text-v2-moe",
|
||||
Address: upstreamAddr,
|
||||
},
|
||||
},
|
||||
Routes: make(map[string]*config.Route),
|
||||
}
|
||||
|
||||
handler := New(cfg)
|
||||
defer handler.Close()
|
||||
|
||||
server := httptest.NewServer(handler)
|
||||
defer server.Close()
|
||||
|
||||
originalBody := `{"model":"nomic-ai/nomic-embed-text-v2-moe","input":"test data with special chars: \u0001"}`
|
||||
resp, err := http.Post(
|
||||
server.URL+"/v1/embeddings",
|
||||
"application/json",
|
||||
bytes.NewReader([]byte(originalBody)),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
// The received body should match the original (though may have different formatting)
|
||||
var orig, received map[string]interface{}
|
||||
json.Unmarshal([]byte(originalBody), &orig)
|
||||
json.Unmarshal([]byte(receivedBody), &received)
|
||||
|
||||
if orig["model"] != received["model"] || orig["input"] != received["input"] {
|
||||
t.Errorf("body was not forwarded correctly")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRerankerBodyForwarded verifies body is byte-identical to upstream
|
||||
func TestRerankerBodyForwarded(t *testing.T) {
|
||||
receivedBody := ""
|
||||
|
||||
upstreamServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
receivedBody = string(body)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
fmt.Fprint(w, `{"results":[]}`)
|
||||
}))
|
||||
defer upstreamServer.Close()
|
||||
|
||||
upstreamAddr := strings.TrimPrefix(upstreamServer.URL, "http://")
|
||||
|
||||
cfg := &config.Config{
|
||||
Models: map[string]*config.ModelUpstream{
|
||||
"BAAI/bge-reranker-base": {
|
||||
Name: "BAAI/bge-reranker-base",
|
||||
Address: upstreamAddr,
|
||||
},
|
||||
},
|
||||
Routes: make(map[string]*config.Route),
|
||||
}
|
||||
|
||||
handler := New(cfg)
|
||||
defer handler.Close()
|
||||
|
||||
server := httptest.NewServer(handler)
|
||||
defer server.Close()
|
||||
|
||||
originalBody := `{"model":"BAAI/bge-reranker-base","query":"test","texts":["a","b","c"]}`
|
||||
resp, err := http.Post(
|
||||
server.URL+"/v1/rerank",
|
||||
"application/json",
|
||||
bytes.NewReader([]byte(originalBody)),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
var orig, received map[string]interface{}
|
||||
json.Unmarshal([]byte(originalBody), &orig)
|
||||
json.Unmarshal([]byte(receivedBody), &received)
|
||||
|
||||
if orig["model"] != received["model"] || orig["query"] != received["query"] {
|
||||
t.Errorf("body was not forwarded correctly")
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpstreamErrorStatusSurfaced verifies upstream errors are returned as-is
|
||||
func TestUpstreamErrorStatusSurfaced(t *testing.T) {
|
||||
upstreamServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
fmt.Fprint(w, `{"error":"upstream failure"}`)
|
||||
}))
|
||||
defer upstreamServer.Close()
|
||||
|
||||
upstreamAddr := strings.TrimPrefix(upstreamServer.URL, "http://")
|
||||
|
||||
cfg := &config.Config{
|
||||
Models: map[string]*config.ModelUpstream{
|
||||
"nomic-ai/nomic-embed-text-v2-moe": {
|
||||
Name: "nomic-ai/nomic-embed-text-v2-moe",
|
||||
Address: upstreamAddr,
|
||||
},
|
||||
},
|
||||
Routes: make(map[string]*config.Route),
|
||||
}
|
||||
|
||||
handler := New(cfg)
|
||||
defer handler.Close()
|
||||
|
||||
server := httptest.NewServer(handler)
|
||||
defer server.Close()
|
||||
|
||||
resp, err := http.Post(
|
||||
server.URL+"/v1/embeddings",
|
||||
"application/json",
|
||||
bytes.NewReader([]byte(`{"model":"nomic-ai/nomic-embed-text-v2-moe","input":"test"}`)),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusInternalServerError {
|
||||
t.Errorf("expected upstream error status 500, got %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Riotpiaole/homelab-frontend/internal/config"
|
||||
"forgejo.riotpiao.com/rock/homelab-frontend/internal/config"
|
||||
)
|
||||
|
||||
// TestHeaderHygiene verifies that headers are properly filtered and forwarded.
|
||||
|
||||
@@ -0,0 +1,470 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"forgejo.riotpiao.com/rock/homelab-frontend/internal/config"
|
||||
)
|
||||
|
||||
// ModelListResponse represents the response shape for GET /v1/models
|
||||
type ModelListResponse struct {
|
||||
Object string `json:"object"`
|
||||
Data []ModelEntry `json:"data"`
|
||||
}
|
||||
|
||||
// ModelEntry represents a single model in the list
|
||||
type ModelEntry struct {
|
||||
ID string `json:"id"`
|
||||
Object string `json:"object"`
|
||||
OwnedBy string `json:"owned_by"`
|
||||
Created int64 `json:"created"`
|
||||
}
|
||||
|
||||
// TestModelsEndpointReturns200 verifies GET /v1/models returns 200
|
||||
func TestModelsEndpointReturns200(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
Models: map[string]*config.ModelUpstream{
|
||||
"reasoning": {
|
||||
Name: "reasoning",
|
||||
Address: "localhost:9000",
|
||||
},
|
||||
},
|
||||
Routes: make(map[string]*config.Route),
|
||||
}
|
||||
|
||||
handler := New(cfg)
|
||||
defer handler.Close()
|
||||
|
||||
server := httptest.NewServer(handler)
|
||||
defer server.Close()
|
||||
|
||||
resp, err := http.Get(server.URL + "/v1/models")
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Errorf("expected 200, got %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// TestModelsEndpointContentType verifies correct content type
|
||||
func TestModelsEndpointContentType(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
Models: map[string]*config.ModelUpstream{
|
||||
"reasoning": {
|
||||
Name: "reasoning",
|
||||
Address: "localhost:9000",
|
||||
},
|
||||
},
|
||||
Routes: make(map[string]*config.Route),
|
||||
}
|
||||
|
||||
handler := New(cfg)
|
||||
defer handler.Close()
|
||||
|
||||
server := httptest.NewServer(handler)
|
||||
defer server.Close()
|
||||
|
||||
resp, err := http.Get(server.URL + "/v1/models")
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
ct := resp.Header.Get("Content-Type")
|
||||
if !strings.Contains(ct, "application/json") {
|
||||
t.Errorf("expected content-type application/json, got %s", ct)
|
||||
}
|
||||
}
|
||||
|
||||
// TestModelsEndpointResponseShape verifies correct JSON structure
|
||||
func TestModelsEndpointResponseShape(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
Models: map[string]*config.ModelUpstream{
|
||||
"reasoning": {
|
||||
Name: "reasoning",
|
||||
Address: "localhost:9000",
|
||||
},
|
||||
},
|
||||
Routes: make(map[string]*config.Route),
|
||||
}
|
||||
|
||||
handler := New(cfg)
|
||||
defer handler.Close()
|
||||
|
||||
server := httptest.NewServer(handler)
|
||||
defer server.Close()
|
||||
|
||||
resp, err := http.Get(server.URL + "/v1/models")
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var result ModelListResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
t.Fatalf("failed to decode response: %v", err)
|
||||
}
|
||||
|
||||
if result.Object != "list" {
|
||||
t.Errorf("expected object='list', got %q", result.Object)
|
||||
}
|
||||
|
||||
if len(result.Data) != 1 {
|
||||
t.Errorf("expected 1 model, got %d", len(result.Data))
|
||||
}
|
||||
|
||||
model := result.Data[0]
|
||||
if model.ID != "reasoning" {
|
||||
t.Errorf("expected id='reasoning', got %q", model.ID)
|
||||
}
|
||||
|
||||
if model.Object != "model" {
|
||||
t.Errorf("expected object='model', got %q", model.Object)
|
||||
}
|
||||
}
|
||||
|
||||
// TestModelsEndpointEnumeratesAllModels verifies all models are listed
|
||||
func TestModelsEndpointEnumeratesAllModels(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
Models: map[string]*config.ModelUpstream{
|
||||
"reasoning": {
|
||||
Name: "reasoning",
|
||||
Address: "localhost:9000",
|
||||
},
|
||||
"ornith:35b": {
|
||||
Name: "ornith:35b",
|
||||
Address: "localhost:9000",
|
||||
},
|
||||
"qwen2.5:3b-instruct": {
|
||||
Name: "qwen2.5:3b-instruct",
|
||||
Address: "localhost:9000",
|
||||
},
|
||||
"nomic-ai/nomic-embed-text-v2-moe": {
|
||||
Name: "nomic-ai/nomic-embed-text-v2-moe",
|
||||
Address: "localhost:9000",
|
||||
},
|
||||
"BAAI/bge-reranker-base": {
|
||||
Name: "BAAI/bge-reranker-base",
|
||||
Address: "localhost:9000",
|
||||
},
|
||||
},
|
||||
Routes: make(map[string]*config.Route),
|
||||
}
|
||||
|
||||
handler := New(cfg)
|
||||
defer handler.Close()
|
||||
|
||||
server := httptest.NewServer(handler)
|
||||
defer server.Close()
|
||||
|
||||
resp, err := http.Get(server.URL + "/v1/models")
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var result ModelListResponse
|
||||
json.NewDecoder(resp.Body).Decode(&result)
|
||||
|
||||
if len(result.Data) != 5 {
|
||||
t.Errorf("expected 5 models, got %d", len(result.Data))
|
||||
}
|
||||
|
||||
// Collect actual model IDs
|
||||
modelIDs := make(map[string]bool)
|
||||
for _, model := range result.Data {
|
||||
modelIDs[model.ID] = true
|
||||
}
|
||||
|
||||
// Verify all expected models are present
|
||||
expectedModels := []string{
|
||||
"reasoning",
|
||||
"ornith:35b",
|
||||
"qwen2.5:3b-instruct",
|
||||
"nomic-ai/nomic-embed-text-v2-moe",
|
||||
"BAAI/bge-reranker-base",
|
||||
}
|
||||
|
||||
for _, expected := range expectedModels {
|
||||
if !modelIDs[expected] {
|
||||
t.Errorf("expected model %q in response", expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestModelsEndpointHasRequiredFields verifies all required fields are present
|
||||
func TestModelsEndpointHasRequiredFields(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
Models: map[string]*config.ModelUpstream{
|
||||
"reasoning": {
|
||||
Name: "reasoning",
|
||||
Address: "localhost:9000",
|
||||
},
|
||||
},
|
||||
Routes: make(map[string]*config.Route),
|
||||
}
|
||||
|
||||
handler := New(cfg)
|
||||
defer handler.Close()
|
||||
|
||||
server := httptest.NewServer(handler)
|
||||
defer server.Close()
|
||||
|
||||
resp, err := http.Get(server.URL + "/v1/models")
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var result ModelListResponse
|
||||
json.NewDecoder(resp.Body).Decode(&result)
|
||||
|
||||
model := result.Data[0]
|
||||
if model.ID == "" {
|
||||
t.Errorf("expected id field")
|
||||
}
|
||||
|
||||
if model.Object == "" {
|
||||
t.Errorf("expected object field")
|
||||
}
|
||||
|
||||
if model.OwnedBy == "" {
|
||||
t.Errorf("expected owned_by field")
|
||||
}
|
||||
|
||||
if model.Created == 0 {
|
||||
t.Errorf("expected created field (unix timestamp)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestModelsEndpointNoUpstreamContact verifies endpoint doesn't contact upstream
|
||||
func TestModelsEndpointNoUpstreamContact(t *testing.T) {
|
||||
upstreamCalled := false
|
||||
upstreamServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
upstreamCalled = true
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer upstreamServer.Close()
|
||||
|
||||
upstreamAddr := strings.TrimPrefix(upstreamServer.URL, "http://")
|
||||
|
||||
cfg := &config.Config{
|
||||
Models: map[string]*config.ModelUpstream{
|
||||
"reasoning": {
|
||||
Name: "reasoning",
|
||||
Address: upstreamAddr,
|
||||
},
|
||||
},
|
||||
Routes: make(map[string]*config.Route),
|
||||
}
|
||||
|
||||
handler := New(cfg)
|
||||
defer handler.Close()
|
||||
|
||||
server := httptest.NewServer(handler)
|
||||
defer server.Close()
|
||||
|
||||
_, _ = http.Get(server.URL + "/v1/models")
|
||||
|
||||
if upstreamCalled {
|
||||
t.Errorf("upstream should not be called for /v1/models endpoint")
|
||||
}
|
||||
}
|
||||
|
||||
// TestModelsEndpointDerivedFromConfig verifies models come from config, not hardcoded
|
||||
func TestModelsEndpointDerivedFromConfig(t *testing.T) {
|
||||
// Create config with specific models
|
||||
cfg := &config.Config{
|
||||
Models: map[string]*config.ModelUpstream{
|
||||
"custom-model-1": {
|
||||
Name: "custom-model-1",
|
||||
Address: "localhost:9000",
|
||||
},
|
||||
"custom-model-2": {
|
||||
Name: "custom-model-2",
|
||||
Address: "localhost:9000",
|
||||
},
|
||||
},
|
||||
Routes: make(map[string]*config.Route),
|
||||
}
|
||||
|
||||
handler := New(cfg)
|
||||
defer handler.Close()
|
||||
|
||||
server := httptest.NewServer(handler)
|
||||
defer server.Close()
|
||||
|
||||
resp, err := http.Get(server.URL + "/v1/models")
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var result ModelListResponse
|
||||
json.NewDecoder(resp.Body).Decode(&result)
|
||||
|
||||
// Verify only the configured models are returned
|
||||
if len(result.Data) != 2 {
|
||||
t.Errorf("expected 2 models from config, got %d", len(result.Data))
|
||||
}
|
||||
|
||||
modelIDs := make([]string, len(result.Data))
|
||||
for i, model := range result.Data {
|
||||
modelIDs[i] = model.ID
|
||||
}
|
||||
sort.Strings(modelIDs)
|
||||
|
||||
expected := []string{"custom-model-1", "custom-model-2"}
|
||||
if !equal(modelIDs, expected) {
|
||||
t.Errorf("expected models %v, got %v", expected, modelIDs)
|
||||
}
|
||||
}
|
||||
|
||||
// TestModelsEndpointConsistentWithDispatch verifies advertised models can dispatch
|
||||
func TestModelsEndpointConsistentWithDispatch(t *testing.T) {
|
||||
upstreamServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`{"choices":[{"message":{"content":"ok"}}]}`))
|
||||
}))
|
||||
defer upstreamServer.Close()
|
||||
|
||||
upstreamAddr := strings.TrimPrefix(upstreamServer.URL, "http://")
|
||||
|
||||
cfg := &config.Config{
|
||||
Models: map[string]*config.ModelUpstream{
|
||||
"reasoning": {
|
||||
Name: "reasoning",
|
||||
Address: upstreamAddr,
|
||||
},
|
||||
"ornith:35b": {
|
||||
Name: "ornith:35b",
|
||||
Address: upstreamAddr,
|
||||
},
|
||||
},
|
||||
Routes: make(map[string]*config.Route),
|
||||
}
|
||||
|
||||
handler := New(cfg)
|
||||
defer handler.Close()
|
||||
|
||||
server := httptest.NewServer(handler)
|
||||
defer server.Close()
|
||||
|
||||
// Get list of models
|
||||
resp, err := http.Get(server.URL + "/v1/models")
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
var result ModelListResponse
|
||||
json.NewDecoder(resp.Body).Decode(&result)
|
||||
resp.Body.Close()
|
||||
|
||||
// Try to dispatch to each advertised model
|
||||
for _, model := range result.Data {
|
||||
dispatchResp, err := http.Post(
|
||||
server.URL+"/v1/chat/completions",
|
||||
"application/json",
|
||||
strings.NewReader(`{"model":"`+model.ID+`","messages":[]}`),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer dispatchResp.Body.Close()
|
||||
|
||||
// Should not return 400 (unknown model error)
|
||||
if dispatchResp.StatusCode == http.StatusBadRequest {
|
||||
body, _ := io.ReadAll(dispatchResp.Body)
|
||||
if strings.Contains(string(body), "unknown model") {
|
||||
t.Errorf("model %q advertised in /v1/models but not accepted for dispatch", model.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestModelsEndpointResponseIsConsistent verifies response is consistent across calls
|
||||
func TestModelsEndpointResponseIsConsistent(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
Models: map[string]*config.ModelUpstream{
|
||||
"reasoning": {
|
||||
Name: "reasoning",
|
||||
Address: "localhost:9000",
|
||||
},
|
||||
"ornith:35b": {
|
||||
Name: "ornith:35b",
|
||||
Address: "localhost:9000",
|
||||
},
|
||||
},
|
||||
Routes: make(map[string]*config.Route),
|
||||
}
|
||||
|
||||
handler := New(cfg)
|
||||
defer handler.Close()
|
||||
|
||||
server := httptest.NewServer(handler)
|
||||
defer server.Close()
|
||||
|
||||
// Call endpoint twice
|
||||
resp1, err := http.Get(server.URL + "/v1/models")
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
var result1 ModelListResponse
|
||||
json.NewDecoder(resp1.Body).Decode(&result1)
|
||||
resp1.Body.Close()
|
||||
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
|
||||
resp2, err := http.Get(server.URL + "/v1/models")
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
var result2 ModelListResponse
|
||||
json.NewDecoder(resp2.Body).Decode(&result2)
|
||||
resp2.Body.Close()
|
||||
|
||||
// Verify both responses have same models
|
||||
if len(result1.Data) != len(result2.Data) {
|
||||
t.Errorf("response length inconsistent: %d vs %d", len(result1.Data), len(result2.Data))
|
||||
}
|
||||
|
||||
ids1 := make([]string, len(result1.Data))
|
||||
ids2 := make([]string, len(result2.Data))
|
||||
|
||||
for i, m := range result1.Data {
|
||||
ids1[i] = m.ID
|
||||
}
|
||||
for i, m := range result2.Data {
|
||||
ids2[i] = m.ID
|
||||
}
|
||||
|
||||
sort.Strings(ids1)
|
||||
sort.Strings(ids2)
|
||||
|
||||
if !equal(ids1, ids2) {
|
||||
t.Errorf("responses differ: %v vs %v", ids1, ids2)
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to compare string slices
|
||||
func equal(a, b []string) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for i := range a {
|
||||
if a[i] != b[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
+144
-2
@@ -2,17 +2,19 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Riotpiaole/homelab-frontend/internal/config"
|
||||
"github.com/Riotpiaole/homelab-frontend/internal/logging"
|
||||
"forgejo.riotpiao.com/rock/homelab-frontend/internal/config"
|
||||
"forgejo.riotpiao.com/rock/homelab-frontend/internal/logging"
|
||||
)
|
||||
|
||||
// Handler is a reverse proxy that routes requests to configured upstreams.
|
||||
@@ -37,6 +39,26 @@ type Route struct {
|
||||
Transport *http.Transport
|
||||
}
|
||||
|
||||
// Error types for model validation
|
||||
type modelValidationError struct {
|
||||
Kind string // "invalid_json", "missing_model", "unknown_model"
|
||||
Message string
|
||||
Model string // only for unknown_model
|
||||
}
|
||||
|
||||
func (e *modelValidationError) Error() string {
|
||||
return e.Message
|
||||
}
|
||||
|
||||
// RFC 9457 Problem Details
|
||||
type problemDetail struct {
|
||||
Type string `json:"type"`
|
||||
Title string `json:"title"`
|
||||
Status int `json:"status"`
|
||||
Detail string `json:"detail"`
|
||||
ValidModels []string `json:"valid_models,omitempty"`
|
||||
}
|
||||
|
||||
// New creates a new reverse proxy handler from configuration.
|
||||
// It sets up connection pooling and rewriting rules for each route.
|
||||
func New(cfg *config.Config) *Handler {
|
||||
@@ -174,10 +196,79 @@ func getPeerIP(remoteAddr string) string {
|
||||
return remoteAddr
|
||||
}
|
||||
|
||||
// writeProblemDetail writes an RFC 9457 problem detail response.
|
||||
func writeProblemDetail(w http.ResponseWriter, status int, problemType, title, detail string, validModels []string) {
|
||||
w.Header().Set("Content-Type", "application/problem+json")
|
||||
w.WriteHeader(status)
|
||||
|
||||
problem := problemDetail{
|
||||
Type: problemType,
|
||||
Title: title,
|
||||
Status: status,
|
||||
Detail: detail,
|
||||
ValidModels: validModels,
|
||||
}
|
||||
|
||||
json.NewEncoder(w).Encode(problem)
|
||||
}
|
||||
|
||||
// ServeHTTP implements http.Handler.
|
||||
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
// Handle /v1/models endpoint (no routing needed, derived from config)
|
||||
if r.URL.Path == "/v1/models" && r.Method == "GET" {
|
||||
h.handleModelsEndpoint(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Try to find a matching route (including body-based dispatch for /v1/chat/completions)
|
||||
route, err := h.RouteRequest(r)
|
||||
|
||||
// Check if this is a model validation error (from body-based dispatch)
|
||||
if validationErr, ok := err.(*modelValidationError); ok {
|
||||
// This is a client error, not a routing error
|
||||
var status int
|
||||
var problemType string
|
||||
var title string
|
||||
var detail string
|
||||
|
||||
switch validationErr.Kind {
|
||||
case "invalid_json":
|
||||
status = http.StatusBadRequest
|
||||
problemType = "https://api.example.com/problems/invalid-request-body"
|
||||
title = "Invalid Request Body"
|
||||
detail = validationErr.Message
|
||||
case "missing_model", "empty_model", "null_model":
|
||||
status = http.StatusBadRequest
|
||||
problemType = "https://api.example.com/problems/missing-model"
|
||||
title = "Missing Model"
|
||||
detail = "The 'model' field is required and must be a non-empty string"
|
||||
case "unknown_model":
|
||||
status = http.StatusBadRequest
|
||||
problemType = "https://api.example.com/problems/unknown-model"
|
||||
title = "Unknown Model"
|
||||
detail = fmt.Sprintf("Model %q is not available. See valid_models for available options.", validationErr.Model)
|
||||
default:
|
||||
status = http.StatusBadRequest
|
||||
problemType = "https://api.example.com/problems/invalid-request"
|
||||
title = "Invalid Request"
|
||||
detail = validationErr.Message
|
||||
}
|
||||
|
||||
// Get list of valid models (only for model-related errors)
|
||||
var validModels []string
|
||||
if validationErr.Kind == "unknown_model" || validationErr.Kind == "missing_model" || validationErr.Kind == "empty_model" || validationErr.Kind == "null_model" {
|
||||
validModels = h.getValidModels()
|
||||
}
|
||||
|
||||
writeProblemDetail(w, status, problemType, title, detail, validModels)
|
||||
logging.Errorf("client error", validationErr, map[string]string{
|
||||
"path": r.URL.Path,
|
||||
"method": r.Method,
|
||||
"reason": validationErr.Kind,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil || route == nil {
|
||||
// Route not found or error determining route
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
@@ -252,6 +343,57 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
|
||||
|
||||
// getValidModels returns a sorted list of all configured model names.
|
||||
func (h *Handler) getValidModels() []string {
|
||||
var models []string
|
||||
for name := range h.config.Models {
|
||||
models = append(models, name)
|
||||
}
|
||||
sort.Strings(models)
|
||||
return models
|
||||
}
|
||||
|
||||
// modelsListResponse represents the response for GET /v1/models
|
||||
type modelsListResponse struct {
|
||||
Object string `json:"object"`
|
||||
Data []modelsListEntry `json:"data"`
|
||||
}
|
||||
|
||||
// modelsListEntry represents a single model in the list
|
||||
type modelsListEntry struct {
|
||||
ID string `json:"id"`
|
||||
Object string `json:"object"`
|
||||
OwnedBy string `json:"owned_by"`
|
||||
Created int64 `json:"created"`
|
||||
}
|
||||
|
||||
// handleModelsEndpoint serves GET /v1/models
|
||||
// Returns a list of all configured models, derived from config not hardcoded
|
||||
func (h *Handler) handleModelsEndpoint(w http.ResponseWriter, r *http.Request) {
|
||||
// Get all model names from config
|
||||
modelNames := h.getValidModels()
|
||||
|
||||
// Build the response
|
||||
data := make([]modelsListEntry, len(modelNames))
|
||||
for i, name := range modelNames {
|
||||
data[i] = modelsListEntry{
|
||||
ID: name,
|
||||
Object: "model",
|
||||
OwnedBy: "api.riotpiao.com",
|
||||
Created: 1700000000, // Fixed timestamp; can be made configurable if needed
|
||||
}
|
||||
}
|
||||
|
||||
response := modelsListResponse{
|
||||
Object: "list",
|
||||
Data: data,
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(response)
|
||||
}
|
||||
|
||||
// Close closes all underlying transports, releasing their connection pools.
|
||||
func (h *Handler) Close() error {
|
||||
for _, transport := range h.transports {
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Riotpiaole/homelab-frontend/internal/config"
|
||||
"forgejo.riotpiao.com/rock/homelab-frontend/internal/config"
|
||||
)
|
||||
|
||||
func TestProxyBasic(t *testing.T) {
|
||||
@@ -105,7 +105,9 @@ func TestProxyPathRewrite(t *testing.T) {
|
||||
server := httptest.NewServer(handler)
|
||||
defer server.Close()
|
||||
|
||||
resp, err := http.Get(server.URL + "/v1/models")
|
||||
// Not /v1/models: ServeHTTP serves that endpoint from config (task 2.5)
|
||||
// and returns before routing, so it never exercises PathRewrite.
|
||||
resp, err := http.Get(server.URL + "/some/path")
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
@@ -402,7 +404,10 @@ func TestProxyPreservesBody(t *testing.T) {
|
||||
defer server.Close()
|
||||
|
||||
testBody := `{"model": "test", "messages": []}`
|
||||
resp, _ := http.Post(server.URL+"/test", "application/json", strings.NewReader(testBody))
|
||||
resp, err := http.Post(server.URL+"/test", "application/json", strings.NewReader(testBody))
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
if receivedBody != testBody {
|
||||
|
||||
+64
-14
@@ -9,16 +9,16 @@ import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
"github.com/Riotpiaole/homelab-frontend/internal/config"
|
||||
"forgejo.riotpiao.com/rock/homelab-frontend/internal/config"
|
||||
)
|
||||
|
||||
// RouteRequest determines which upstream should handle the request.
|
||||
// For /v1/chat/completions, it uses body-based dispatch (reads JSON to find "model" field).
|
||||
// For other routes, it looks up by path prefix.
|
||||
// For /v1/chat/completions, /v1/embeddings, and /v1/rerank, it uses body-based dispatch.
|
||||
// For other routes, it looks up by path in the configured routes.
|
||||
func (h *Handler) RouteRequest(r *http.Request) (*Route, error) {
|
||||
// For /v1/chat/completions, use body-based dispatch
|
||||
if r.URL.Path == "/v1/chat/completions" && r.Method == "POST" {
|
||||
return h.routeByModel(r)
|
||||
// For /v1/chat/completions, /v1/embeddings, /v1/rerank use body-based dispatch
|
||||
if r.Method == "POST" && (r.URL.Path == "/v1/chat/completions" || r.URL.Path == "/v1/embeddings" || r.URL.Path == "/v1/rerank") {
|
||||
return h.routeByModel(r, r.URL.Path)
|
||||
}
|
||||
|
||||
// For other paths, try to find a matching route by path
|
||||
@@ -45,17 +45,25 @@ func (h *Handler) RouteRequest(r *http.Request) (*Route, error) {
|
||||
|
||||
// routeByModel reads the request body to find the "model" field and routes accordingly.
|
||||
// The body is preserved for forwarding to the upstream.
|
||||
func (h *Handler) routeByModel(r *http.Request) (*Route, error) {
|
||||
// Returns a modelValidationError for client errors (invalid JSON, missing/unknown model).
|
||||
// The path parameter indicates which endpoint is being called (/v1/chat/completions, /v1/embeddings, /v1/rerank)
|
||||
func (h *Handler) routeByModel(r *http.Request, path string) (*Route, error) {
|
||||
// If there's no body, we can't determine the model
|
||||
if r.Body == nil {
|
||||
return nil, fmt.Errorf("request body required")
|
||||
return nil, &modelValidationError{
|
||||
Kind: "missing_model",
|
||||
Message: "request body required",
|
||||
}
|
||||
}
|
||||
|
||||
// Read the body to extract the model name
|
||||
// We need to be careful to preserve the body for the upstream
|
||||
bodyBytes, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read request body: %w", err)
|
||||
return nil, &modelValidationError{
|
||||
Kind: "invalid_request",
|
||||
Message: fmt.Sprintf("failed to read request body: %v", err),
|
||||
}
|
||||
}
|
||||
|
||||
// Restore the body so it can be read again by the upstream
|
||||
@@ -64,26 +72,68 @@ func (h *Handler) routeByModel(r *http.Request) (*Route, error) {
|
||||
// Parse the JSON to find the model field
|
||||
var payload map[string]interface{}
|
||||
if err := json.Unmarshal(bodyBytes, &payload); err != nil {
|
||||
return nil, fmt.Errorf("invalid JSON in request body: %w", err)
|
||||
return nil, &modelValidationError{
|
||||
Kind: "invalid_json",
|
||||
Message: "request body is not valid JSON",
|
||||
}
|
||||
}
|
||||
|
||||
// Extract the model name
|
||||
modelName, ok := payload["model"].(string)
|
||||
modelVal, hasModel := payload["model"]
|
||||
if !hasModel {
|
||||
return nil, &modelValidationError{
|
||||
Kind: "missing_model",
|
||||
Message: "'model' field is missing",
|
||||
}
|
||||
}
|
||||
|
||||
// Handle null model
|
||||
if modelVal == nil {
|
||||
return nil, &modelValidationError{
|
||||
Kind: "null_model",
|
||||
Message: "'model' field is null",
|
||||
}
|
||||
}
|
||||
|
||||
// Extract as string
|
||||
modelName, ok := modelVal.(string)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("model field missing or not a string")
|
||||
return nil, &modelValidationError{
|
||||
Kind: "missing_model",
|
||||
Message: "'model' field must be a string",
|
||||
}
|
||||
}
|
||||
|
||||
// Handle empty string
|
||||
if modelName == "" {
|
||||
return nil, &modelValidationError{
|
||||
Kind: "empty_model",
|
||||
Message: "'model' field cannot be empty",
|
||||
}
|
||||
}
|
||||
|
||||
// Look up the model in the registry
|
||||
modelUpstream := h.config.LookupModel(modelName)
|
||||
if modelUpstream == nil {
|
||||
return nil, fmt.Errorf("unknown model: %q", modelName)
|
||||
return nil, &modelValidationError{
|
||||
Kind: "unknown_model",
|
||||
Message: fmt.Sprintf("unknown model: %q", modelName),
|
||||
Model: modelName,
|
||||
}
|
||||
}
|
||||
|
||||
// Determine the upstream path based on the request path
|
||||
upstreamPath := path
|
||||
if path == "/v1/rerank" {
|
||||
// Rerank endpoint uses /rerank path on upstream
|
||||
upstreamPath = "/rerank"
|
||||
}
|
||||
|
||||
// Create a route for this model with appropriate timeouts
|
||||
// These are sensible defaults for LLM models
|
||||
upstreamCfg := &config.Upstream{
|
||||
Address: modelUpstream.Address,
|
||||
PathRewrite: "/v1/chat/completions",
|
||||
PathRewrite: upstreamPath,
|
||||
ConnectTimeout: h.defaultConnectTimeout,
|
||||
ReadTimeout: h.defaultReadTimeout,
|
||||
WriteTimeout: h.defaultWriteTimeout,
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Riotpiaole/homelab-frontend/internal/config"
|
||||
"forgejo.riotpiao.com/rock/homelab-frontend/internal/config"
|
||||
)
|
||||
|
||||
// TestSSEUnbuffered verifies that SSE events stream to the client without buffering.
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Riotpiaole/homelab-frontend/internal/config"
|
||||
"forgejo.riotpiao.com/rock/homelab-frontend/internal/config"
|
||||
)
|
||||
|
||||
// TestConnectTimeout verifies that connections fail at the configured timeout.
|
||||
@@ -102,7 +102,10 @@ func TestReadTimeout(t *testing.T) {
|
||||
defer server.Close()
|
||||
|
||||
start := time.Now()
|
||||
resp, _ := http.Get(server.URL + "/test")
|
||||
resp, err := http.Get(server.URL + "/test")
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
elapsed := time.Since(start)
|
||||
|
||||
// Should timeout around the read timeout (with some tolerance)
|
||||
|
||||
@@ -11,13 +11,13 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Riotpiaole/homelab-frontend/internal/config"
|
||||
"forgejo.riotpiao.com/rock/homelab-frontend/internal/config"
|
||||
)
|
||||
|
||||
// OpenAI-style tool definition
|
||||
type Tool struct {
|
||||
Type string `json:"type"`
|
||||
Function ToolFunction `json:"function"`
|
||||
Type string `json:"type"`
|
||||
Function ToolFunction `json:"function"`
|
||||
}
|
||||
|
||||
type ToolFunction struct {
|
||||
@@ -28,21 +28,21 @@ type ToolFunction struct {
|
||||
|
||||
// OpenAI chat completion with tools request
|
||||
type ChatCompletionRequest struct {
|
||||
Model string `json:"model"`
|
||||
Messages []Message `json:"messages"`
|
||||
Tools []Tool `json:"tools,omitempty"`
|
||||
Stream bool `json:"stream,omitempty"`
|
||||
Model string `json:"model"`
|
||||
Messages []Message `json:"messages"`
|
||||
Tools []Tool `json:"tools,omitempty"`
|
||||
Stream bool `json:"stream,omitempty"`
|
||||
}
|
||||
|
||||
type Message struct {
|
||||
Role string `json:"role"`
|
||||
Content interface{} `json:"content"`
|
||||
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
|
||||
Role string `json:"role"`
|
||||
Content interface{} `json:"content"`
|
||||
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
|
||||
}
|
||||
|
||||
type ToolCall struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Function FunctionCall `json:"function"`
|
||||
}
|
||||
|
||||
@@ -64,21 +64,21 @@ func TestToolCallOpenAIStyle(t *testing.T) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
|
||||
response := map[string]interface{}{
|
||||
"id": "chatcmpl-123",
|
||||
"object": "chat.completion",
|
||||
"model": "reasoning",
|
||||
"id": "chatcmpl-123",
|
||||
"object": "chat.completion",
|
||||
"model": "reasoning",
|
||||
"choices": []map[string]interface{}{
|
||||
{
|
||||
"index": 0,
|
||||
"message": map[string]interface{}{
|
||||
"role": "assistant",
|
||||
"role": "assistant",
|
||||
"content": nil,
|
||||
"tool_calls": []map[string]interface{}{
|
||||
{
|
||||
"id": "call_abc123",
|
||||
"id": "call_abc123",
|
||||
"type": "function",
|
||||
"function": map[string]interface{}{
|
||||
"name": "get_weather",
|
||||
"name": "get_weather",
|
||||
"arguments": `{"location":"San Francisco","unit":"celsius"}`,
|
||||
},
|
||||
},
|
||||
@@ -372,10 +372,10 @@ func TestToolCallMultiTurn(t *testing.T) {
|
||||
"content": nil,
|
||||
"tool_calls": []map[string]interface{}{
|
||||
{
|
||||
"id": "call_abc123",
|
||||
"id": "call_abc123",
|
||||
"type": "function",
|
||||
"function": map[string]interface{}{
|
||||
"name": "get_weather",
|
||||
"name": "get_weather",
|
||||
"arguments": `{"location":"San Francisco"}`,
|
||||
},
|
||||
},
|
||||
@@ -430,7 +430,10 @@ func TestToolCallMultiTurn(t *testing.T) {
|
||||
}
|
||||
|
||||
body1, _ := json.Marshal(turn1)
|
||||
resp1, _ := http.Post(server.URL+"/v1/chat/completions", "application/json", bytes.NewReader(body1))
|
||||
resp1, err := http.Post(server.URL+"/v1/chat/completions", "application/json", bytes.NewReader(body1))
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
var turn1Resp map[string]interface{}
|
||||
json.NewDecoder(resp1.Body).Decode(&turn1Resp)
|
||||
resp1.Body.Close()
|
||||
@@ -472,7 +475,10 @@ func TestToolCallMultiTurn(t *testing.T) {
|
||||
}
|
||||
|
||||
body2, _ := json.Marshal(turn2)
|
||||
resp2, _ := http.Post(server.URL+"/v1/chat/completions", "application/json", bytes.NewReader(body2))
|
||||
resp2, err := http.Post(server.URL+"/v1/chat/completions", "application/json", bytes.NewReader(body2))
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
var turn2Resp map[string]interface{}
|
||||
json.NewDecoder(resp2.Body).Decode(&turn2Resp)
|
||||
resp2.Body.Close()
|
||||
@@ -503,7 +509,7 @@ func TestParallelToolCalls(t *testing.T) {
|
||||
"content": nil,
|
||||
"tool_calls": []map[string]interface{}{
|
||||
{
|
||||
"id": "call_1",
|
||||
"id": "call_1",
|
||||
"type": "function",
|
||||
"function": map[string]interface{}{
|
||||
"name": "get_weather",
|
||||
@@ -511,7 +517,7 @@ func TestParallelToolCalls(t *testing.T) {
|
||||
},
|
||||
},
|
||||
{
|
||||
"id": "call_2",
|
||||
"id": "call_2",
|
||||
"type": "function",
|
||||
"function": map[string]interface{}{
|
||||
"name": "get_weather",
|
||||
@@ -519,7 +525,7 @@ func TestParallelToolCalls(t *testing.T) {
|
||||
},
|
||||
},
|
||||
{
|
||||
"id": "call_3",
|
||||
"id": "call_3",
|
||||
"type": "function",
|
||||
"function": map[string]interface{}{
|
||||
"name": "get_weather",
|
||||
@@ -576,7 +582,10 @@ func TestParallelToolCalls(t *testing.T) {
|
||||
}
|
||||
|
||||
body, _ := json.Marshal(request)
|
||||
resp, _ := http.Post(server.URL+"/v1/chat/completions", "application/json", bytes.NewReader(body))
|
||||
resp, err := http.Post(server.URL+"/v1/chat/completions", "application/json", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
var respData map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&respData)
|
||||
resp.Body.Close()
|
||||
@@ -627,9 +636,9 @@ func TestAnthropicToolUse(t *testing.T) {
|
||||
|
||||
// Anthropic response format with tool_use block
|
||||
response := map[string]interface{}{
|
||||
"id": "msg_123",
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"id": "msg_123",
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"content": []map[string]interface{}{
|
||||
{
|
||||
"type": "text",
|
||||
@@ -714,7 +723,10 @@ func TestAnthropicToolUse(t *testing.T) {
|
||||
body, _ := json.Marshal(anthropicRequest)
|
||||
// Note: For now we route through a generic path
|
||||
// In Phase 2.9+, this would be integrated with the Anthropic dialect handler
|
||||
resp, _ := http.Post(server.URL+"/v1/messages", "application/json", bytes.NewReader(body))
|
||||
resp, err := http.Post(server.URL+"/v1/messages", "application/json", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
var respData map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&respData)
|
||||
resp.Body.Close()
|
||||
@@ -762,7 +774,7 @@ func TestComplexToolArguments(t *testing.T) {
|
||||
"content": nil,
|
||||
"tool_calls": []map[string]interface{}{
|
||||
{
|
||||
"id": "call_complex",
|
||||
"id": "call_complex",
|
||||
"type": "function",
|
||||
"function": map[string]interface{}{
|
||||
"name": "create_event",
|
||||
@@ -832,7 +844,10 @@ func TestComplexToolArguments(t *testing.T) {
|
||||
}
|
||||
|
||||
body, _ := json.Marshal(request)
|
||||
resp, _ := http.Post(server.URL+"/v1/chat/completions", "application/json", bytes.NewReader(body))
|
||||
resp, err := http.Post(server.URL+"/v1/chat/completions", "application/json", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
var respData map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&respData)
|
||||
resp.Body.Close()
|
||||
|
||||
@@ -0,0 +1,513 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"forgejo.riotpiao.com/rock/homelab-frontend/internal/config"
|
||||
)
|
||||
|
||||
// RFC 9457 Problem Details
|
||||
type ProblemDetail struct {
|
||||
Type string `json:"type"`
|
||||
Title string `json:"title"`
|
||||
Status int `json:"status"`
|
||||
Detail string `json:"detail"`
|
||||
Instance string `json:"instance,omitempty"`
|
||||
Extra map[string]interface{} `json:"-"`
|
||||
}
|
||||
|
||||
// UnmarshalJSON allows capturing extra fields
|
||||
func (p *ProblemDetail) UnmarshalJSON(data []byte) error {
|
||||
type Alias ProblemDetail
|
||||
aux := &struct {
|
||||
*Alias
|
||||
}{
|
||||
Alias: (*Alias)(p),
|
||||
}
|
||||
if err := json.Unmarshal(data, &aux); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Capture extra fields
|
||||
var raw map[string]interface{}
|
||||
json.Unmarshal(data, &raw)
|
||||
extra := make(map[string]interface{})
|
||||
for k, v := range raw {
|
||||
if k != "type" && k != "title" && k != "status" && k != "detail" && k != "instance" {
|
||||
extra[k] = v
|
||||
}
|
||||
}
|
||||
p.Extra = extra
|
||||
return nil
|
||||
}
|
||||
|
||||
// TestUnknownModelReturns4xx verifies unknown model returns client error
|
||||
func TestUnknownModelReturns4xx(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
Models: map[string]*config.ModelUpstream{
|
||||
"reasoning": {
|
||||
Name: "reasoning",
|
||||
Address: "reasoning-predictor:80",
|
||||
Path: "/v1/chat/completions",
|
||||
},
|
||||
"ornith:35b": {
|
||||
Name: "ornith:35b",
|
||||
Address: "ornith-predictor:80",
|
||||
Path: "/v1/chat/completions",
|
||||
},
|
||||
},
|
||||
Routes: make(map[string]*config.Route),
|
||||
}
|
||||
|
||||
handler := New(cfg)
|
||||
defer handler.Close()
|
||||
|
||||
server := httptest.NewServer(handler)
|
||||
defer server.Close()
|
||||
|
||||
resp, err := http.Post(
|
||||
server.URL+"/v1/chat/completions",
|
||||
"application/json",
|
||||
bytes.NewReader([]byte(`{"model":"gpt-4","messages":[]}`)),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Verify 4xx status
|
||||
if resp.StatusCode < 400 || resp.StatusCode >= 500 {
|
||||
t.Errorf("expected 4xx status for unknown model, got %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// Verify RFC 9457 content type
|
||||
ct := resp.Header.Get("Content-Type")
|
||||
if !strings.Contains(ct, "application/problem+json") {
|
||||
t.Errorf("expected content-type application/problem+json, got %s", ct)
|
||||
}
|
||||
|
||||
// Verify response is valid problem detail
|
||||
var prob ProblemDetail
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if err := json.Unmarshal(body, &prob); err != nil {
|
||||
t.Errorf("response is not valid JSON: %v", err)
|
||||
}
|
||||
|
||||
if prob.Status == 0 {
|
||||
t.Errorf("expected status in problem detail")
|
||||
}
|
||||
|
||||
if prob.Title == "" {
|
||||
t.Errorf("expected title in problem detail")
|
||||
}
|
||||
}
|
||||
|
||||
// TestUnknownModelEnumeratesValidModels verifies all models are listed
|
||||
func TestUnknownModelEnumeratesValidModels(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
Models: map[string]*config.ModelUpstream{
|
||||
"reasoning": {
|
||||
Name: "reasoning",
|
||||
Address: "reasoning-predictor:80",
|
||||
},
|
||||
"ornith:35b": {
|
||||
Name: "ornith:35b",
|
||||
Address: "ornith-predictor:80",
|
||||
},
|
||||
"qwen2.5:3b-instruct": {
|
||||
Name: "qwen2.5:3b-instruct",
|
||||
Address: "ornith-predictor:80",
|
||||
},
|
||||
},
|
||||
Routes: make(map[string]*config.Route),
|
||||
}
|
||||
|
||||
handler := New(cfg)
|
||||
defer handler.Close()
|
||||
|
||||
server := httptest.NewServer(handler)
|
||||
defer server.Close()
|
||||
|
||||
resp, err := http.Post(
|
||||
server.URL+"/v1/chat/completions",
|
||||
"application/json",
|
||||
bytes.NewReader([]byte(`{"model":"unknown","messages":[]}`)),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var prob map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&prob)
|
||||
|
||||
// Check for valid_models field (as an extra field beyond RFC 9457)
|
||||
validModels, hasModels := prob["valid_models"]
|
||||
if !hasModels {
|
||||
t.Errorf("expected valid_models field in problem detail")
|
||||
return
|
||||
}
|
||||
|
||||
models := validModels.([]interface{})
|
||||
if len(models) != 3 {
|
||||
t.Errorf("expected 3 models in valid_models, got %d", len(models))
|
||||
}
|
||||
|
||||
modelNames := make(map[string]bool)
|
||||
for _, m := range models {
|
||||
modelNames[m.(string)] = true
|
||||
}
|
||||
|
||||
expectedModels := []string{"reasoning", "ornith:35b", "qwen2.5:3b-instruct"}
|
||||
for _, expected := range expectedModels {
|
||||
if !modelNames[expected] {
|
||||
t.Errorf("expected model %s in valid_models", expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestMissingModelFieldReturns4xx verifies missing model field is client error
|
||||
func TestMissingModelFieldReturns4xx(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
Models: map[string]*config.ModelUpstream{
|
||||
"reasoning": {
|
||||
Name: "reasoning",
|
||||
Address: "reasoning-predictor:80",
|
||||
},
|
||||
},
|
||||
Routes: make(map[string]*config.Route),
|
||||
}
|
||||
|
||||
handler := New(cfg)
|
||||
defer handler.Close()
|
||||
|
||||
server := httptest.NewServer(handler)
|
||||
defer server.Close()
|
||||
|
||||
// Request with no model field
|
||||
resp, err := http.Post(
|
||||
server.URL+"/v1/chat/completions",
|
||||
"application/json",
|
||||
bytes.NewReader([]byte(`{"messages":[]}`)),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode < 400 || resp.StatusCode >= 500 {
|
||||
t.Errorf("expected 4xx for missing model, got %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
ct := resp.Header.Get("Content-Type")
|
||||
if !strings.Contains(ct, "application/problem+json") {
|
||||
t.Errorf("expected problem+json for missing model")
|
||||
}
|
||||
|
||||
// Verify body does not contain the request
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if strings.Contains(string(body), "messages") {
|
||||
t.Errorf("response should not echo request body")
|
||||
}
|
||||
}
|
||||
|
||||
// TestNullModelFieldReturns4xx verifies null model is client error
|
||||
func TestNullModelFieldReturns4xx(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
Models: map[string]*config.ModelUpstream{
|
||||
"reasoning": {
|
||||
Name: "reasoning",
|
||||
Address: "reasoning-predictor:80",
|
||||
},
|
||||
},
|
||||
Routes: make(map[string]*config.Route),
|
||||
}
|
||||
|
||||
handler := New(cfg)
|
||||
defer handler.Close()
|
||||
|
||||
server := httptest.NewServer(handler)
|
||||
defer server.Close()
|
||||
|
||||
// Request with null model
|
||||
resp, err := http.Post(
|
||||
server.URL+"/v1/chat/completions",
|
||||
"application/json",
|
||||
bytes.NewReader([]byte(`{"model":null,"messages":[]}`)),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode < 400 || resp.StatusCode >= 500 {
|
||||
t.Errorf("expected 4xx for null model, got %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
ct := resp.Header.Get("Content-Type")
|
||||
if !strings.Contains(ct, "application/problem+json") {
|
||||
t.Errorf("expected problem+json for null model")
|
||||
}
|
||||
}
|
||||
|
||||
// TestEmptyModelFieldReturns4xx verifies empty model string is client error
|
||||
func TestEmptyModelFieldReturns4xx(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
Models: map[string]*config.ModelUpstream{
|
||||
"reasoning": {
|
||||
Name: "reasoning",
|
||||
Address: "reasoning-predictor:80",
|
||||
},
|
||||
},
|
||||
Routes: make(map[string]*config.Route),
|
||||
}
|
||||
|
||||
handler := New(cfg)
|
||||
defer handler.Close()
|
||||
|
||||
server := httptest.NewServer(handler)
|
||||
defer server.Close()
|
||||
|
||||
// Request with empty model string
|
||||
resp, err := http.Post(
|
||||
server.URL+"/v1/chat/completions",
|
||||
"application/json",
|
||||
bytes.NewReader([]byte(`{"model":"","messages":[]}`)),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode < 400 || resp.StatusCode >= 500 {
|
||||
t.Errorf("expected 4xx for empty model, got %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// TestInvalidJSONIsDistinguishableError verifies invalid JSON is separate from unknown model
|
||||
func TestInvalidJSONIsDistinguishableError(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
Models: map[string]*config.ModelUpstream{
|
||||
"reasoning": {
|
||||
Name: "reasoning",
|
||||
Address: "reasoning-predictor:80",
|
||||
},
|
||||
},
|
||||
Routes: make(map[string]*config.Route),
|
||||
}
|
||||
|
||||
handler := New(cfg)
|
||||
defer handler.Close()
|
||||
|
||||
server := httptest.NewServer(handler)
|
||||
defer server.Close()
|
||||
|
||||
// Request with invalid JSON
|
||||
resp, err := http.Post(
|
||||
server.URL+"/v1/chat/completions",
|
||||
"application/json",
|
||||
bytes.NewReader([]byte(`not json`)),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode < 400 || resp.StatusCode >= 500 {
|
||||
t.Errorf("expected 4xx for invalid JSON, got %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var prob map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&prob)
|
||||
|
||||
// Invalid JSON error should mention JSON parsing, not model
|
||||
detail := prob["detail"].(string)
|
||||
if !strings.Contains(strings.ToLower(detail), "json") {
|
||||
t.Errorf("expected detail to mention JSON for invalid JSON error")
|
||||
}
|
||||
}
|
||||
|
||||
// TestUnknownModelDoesNotContactUpstream verifies no upstream call is made
|
||||
func TestUnknownModelDoesNotContactUpstream(t *testing.T) {
|
||||
upstreamCalled := false
|
||||
upstreamServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
upstreamCalled = true
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer upstreamServer.Close()
|
||||
|
||||
upstreamAddr := strings.TrimPrefix(upstreamServer.URL, "http://")
|
||||
|
||||
cfg := &config.Config{
|
||||
Models: map[string]*config.ModelUpstream{
|
||||
"reasoning": {
|
||||
Name: "reasoning",
|
||||
Address: upstreamAddr,
|
||||
},
|
||||
},
|
||||
Routes: make(map[string]*config.Route),
|
||||
}
|
||||
|
||||
handler := New(cfg)
|
||||
defer handler.Close()
|
||||
|
||||
server := httptest.NewServer(handler)
|
||||
defer server.Close()
|
||||
|
||||
_, _ = http.Post(
|
||||
server.URL+"/v1/chat/completions",
|
||||
"application/json",
|
||||
bytes.NewReader([]byte(`{"model":"unknown","messages":[]}`)),
|
||||
)
|
||||
|
||||
if upstreamCalled {
|
||||
t.Errorf("upstream should not be called for unknown model")
|
||||
}
|
||||
}
|
||||
|
||||
// TestUnknownModelLogsReason verifies rejection is logged
|
||||
func TestUnknownModelLogsReason(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
Models: map[string]*config.ModelUpstream{
|
||||
"reasoning": {
|
||||
Name: "reasoning",
|
||||
Address: "reasoning-predictor:80",
|
||||
},
|
||||
},
|
||||
Routes: make(map[string]*config.Route),
|
||||
}
|
||||
|
||||
handler := New(cfg)
|
||||
defer handler.Close()
|
||||
|
||||
server := httptest.NewServer(handler)
|
||||
defer server.Close()
|
||||
|
||||
// This test verifies logging behavior by checking the handler's logger output
|
||||
// In a real scenario, you'd capture stderr or use a test logger
|
||||
_, _ = http.Post(
|
||||
server.URL+"/v1/chat/completions",
|
||||
"application/json",
|
||||
bytes.NewReader([]byte(`{"model":"gpt-4","messages":[]}`)),
|
||||
)
|
||||
|
||||
// Logging is verified by checking that no panic occurs
|
||||
// and the request completes successfully
|
||||
}
|
||||
|
||||
// TestMissingModelAndUnknownModelBothReturn4xx verifies consistent error class
|
||||
func TestMissingModelAndUnknownModelBothReturn4xx(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
Models: map[string]*config.ModelUpstream{
|
||||
"reasoning": {
|
||||
Name: "reasoning",
|
||||
Address: "reasoning-predictor:80",
|
||||
},
|
||||
},
|
||||
Routes: make(map[string]*config.Route),
|
||||
}
|
||||
|
||||
handler := New(cfg)
|
||||
defer handler.Close()
|
||||
|
||||
server := httptest.NewServer(handler)
|
||||
defer server.Close()
|
||||
|
||||
// Test missing model
|
||||
resp1, err := http.Post(
|
||||
server.URL+"/v1/chat/completions",
|
||||
"application/json",
|
||||
bytes.NewReader([]byte(`{"messages":[]}`)),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
resp1.Body.Close()
|
||||
|
||||
// Test unknown model
|
||||
resp2, err := http.Post(
|
||||
server.URL+"/v1/chat/completions",
|
||||
"application/json",
|
||||
bytes.NewReader([]byte(`{"model":"unknown","messages":[]}`)),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
resp2.Body.Close()
|
||||
|
||||
// Both should be in 4xx range
|
||||
if resp1.StatusCode < 400 || resp1.StatusCode >= 500 {
|
||||
t.Errorf("expected 4xx for missing model, got %d", resp1.StatusCode)
|
||||
}
|
||||
|
||||
if resp2.StatusCode < 400 || resp2.StatusCode >= 500 {
|
||||
t.Errorf("expected 4xx for unknown model, got %d", resp2.StatusCode)
|
||||
}
|
||||
|
||||
// Both should be problem+json
|
||||
ct1 := resp1.Header.Get("Content-Type")
|
||||
ct2 := resp2.Header.Get("Content-Type")
|
||||
|
||||
if !strings.Contains(ct1, "application/problem+json") {
|
||||
t.Errorf("expected problem+json for missing model")
|
||||
}
|
||||
|
||||
if !strings.Contains(ct2, "application/problem+json") {
|
||||
t.Errorf("expected problem+json for unknown model")
|
||||
}
|
||||
}
|
||||
|
||||
// TestProblemDetailHasRequiredFields verifies RFC 9457 compliance
|
||||
func TestProblemDetailHasRequiredFields(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
Models: map[string]*config.ModelUpstream{
|
||||
"reasoning": {
|
||||
Name: "reasoning",
|
||||
Address: "reasoning-predictor:80",
|
||||
},
|
||||
},
|
||||
Routes: make(map[string]*config.Route),
|
||||
}
|
||||
|
||||
handler := New(cfg)
|
||||
defer handler.Close()
|
||||
|
||||
server := httptest.NewServer(handler)
|
||||
defer server.Close()
|
||||
|
||||
resp, err := http.Post(
|
||||
server.URL+"/v1/chat/completions",
|
||||
"application/json",
|
||||
bytes.NewReader([]byte(`{"model":"unknown","messages":[]}`)),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var prob map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&prob)
|
||||
|
||||
// RFC 9457 required fields
|
||||
if prob["type"] == nil {
|
||||
t.Errorf("expected 'type' field in problem detail")
|
||||
}
|
||||
|
||||
if prob["title"] == nil {
|
||||
t.Errorf("expected 'title' field in problem detail")
|
||||
}
|
||||
|
||||
if prob["status"] == nil {
|
||||
t.Errorf("expected 'status' field in problem detail")
|
||||
}
|
||||
|
||||
if prob["detail"] == nil {
|
||||
t.Errorf("expected 'detail' field in problem detail")
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Riotpiaole/homelab-frontend/internal/server"
|
||||
"forgejo.riotpiao.com/rock/homelab-frontend/internal/server"
|
||||
)
|
||||
|
||||
// TestHealthEndpoints verifies health endpoint behavior.
|
||||
|
||||
@@ -21,11 +21,17 @@ type Server struct {
|
||||
func New(listenAddr string, shutdownTimeout time.Duration, handler http.Handler) *Server {
|
||||
return &Server{
|
||||
httpServer: &http.Server{
|
||||
Addr: listenAddr,
|
||||
Handler: handler,
|
||||
ReadTimeout: 15 * time.Second,
|
||||
WriteTimeout: 15 * time.Second,
|
||||
IdleTimeout: 60 * time.Second,
|
||||
Addr: listenAddr,
|
||||
Handler: handler,
|
||||
// ReadHeaderTimeout (not ReadTimeout) and a long WriteTimeout: both
|
||||
// ReadTimeout and WriteTimeout are absolute deadlines covering the
|
||||
// whole request/response body, not inactivity timeouts -- a 15s
|
||||
// WriteTimeout here was killing in-progress LLM SSE streams (proxy.go's
|
||||
// outbound transport deliberately avoids this same mistake). Mirrors
|
||||
// the edge nginx Ingress's proxy-read/send-timeout of 3600s.
|
||||
ReadHeaderTimeout: 15 * time.Second,
|
||||
WriteTimeout: 1 * time.Hour,
|
||||
IdleTimeout: 60 * time.Second,
|
||||
},
|
||||
shutdownTimeout: shutdownTimeout,
|
||||
healthChecker: NewHealthChecker(false, false),
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Riotpiaole/homelab-frontend/internal/server"
|
||||
"forgejo.riotpiao.com/rock/homelab-frontend/internal/server"
|
||||
)
|
||||
|
||||
// TestGracefulShutdown verifies that:
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Riotpiaole/homelab-frontend/internal/config"
|
||||
"forgejo.riotpiao.com/rock/homelab-frontend/internal/config"
|
||||
)
|
||||
|
||||
// startForTest binds the stub on an ephemeral port so the suite does not fight
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
package workflow
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"go.temporal.io/sdk/workflow"
|
||||
)
|
||||
|
||||
// HelloWorldWorkflow is a simple hello world workflow
|
||||
func HelloWorldWorkflow(ctx workflow.Context, name string) (string, error) {
|
||||
opts := workflow.ActivityOptions{
|
||||
StartToCloseTimeout: time.Minute,
|
||||
}
|
||||
ctx = workflow.WithActivityOptions(ctx, opts)
|
||||
|
||||
var result string
|
||||
if err := workflow.ExecuteActivity(ctx, GreetActivity, name).Get(ctx, &result); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GreetActivity greets someone
|
||||
func GreetActivity(ctx context.Context, name string) (string, error) {
|
||||
return fmt.Sprintf("Hello, %s!", name), nil
|
||||
}
|
||||
|
||||
// ValidateOrderActivity validates an order
|
||||
func ValidateOrderActivity(ctx context.Context, orderID string) (bool, error) {
|
||||
// Simulate validation
|
||||
if orderID == "" {
|
||||
return false, nil
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// ProcessPaymentActivity processes payment
|
||||
func ProcessPaymentActivity(ctx context.Context, orderID string) (string, error) {
|
||||
// Simulate payment processing
|
||||
return fmt.Sprintf("payment-%s", orderID[:min(len(orderID), 3)]), nil
|
||||
}
|
||||
|
||||
// NotifyCustomerActivity sends notification
|
||||
func NotifyCustomerActivity(ctx context.Context, orderID string) (string, error) {
|
||||
// Simulate notification
|
||||
return fmt.Sprintf("notified for order %s", orderID), nil
|
||||
}
|
||||
|
||||
func min(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
package workflow
|
||||
|
||||
import (
|
||||
"log"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"go.temporal.io/sdk/client"
|
||||
"go.temporal.io/sdk/worker"
|
||||
"go.temporal.io/sdk/temporal"
|
||||
"go.temporal.io/sdk/workflow"
|
||||
)
|
||||
|
||||
// WorkerConfig holds worker configuration
|
||||
type WorkerConfig struct {
|
||||
HostPort string
|
||||
Namespace string
|
||||
TaskQueue string
|
||||
}
|
||||
|
||||
// NewWorker creates and starts a Temporal worker
|
||||
func NewWorker(cfg WorkerConfig) error {
|
||||
// Connect to Temporal server
|
||||
c, err := client.Dial(client.Options{
|
||||
HostPort: cfg.HostPort,
|
||||
Namespace: cfg.Namespace,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer c.Close()
|
||||
|
||||
log.Printf("Connected to Temporal at %s (namespace: %s)", cfg.HostPort, cfg.Namespace)
|
||||
|
||||
// Create worker
|
||||
w := worker.New(c, cfg.TaskQueue, worker.Options{})
|
||||
|
||||
// Register workflows
|
||||
w.RegisterWorkflow(HelloWorldWorkflow)
|
||||
w.RegisterWorkflow(GreeterWorkflow)
|
||||
w.RegisterWorkflow(ProcessOrderWorkflow)
|
||||
|
||||
// Register activities
|
||||
w.RegisterActivity(GreetActivity)
|
||||
w.RegisterActivity(ValidateOrderActivity)
|
||||
w.RegisterActivity(ProcessPaymentActivity)
|
||||
w.RegisterActivity(NotifyCustomerActivity)
|
||||
|
||||
// Start worker (blocks until signal received)
|
||||
log.Printf("Starting worker on task queue: %s", cfg.TaskQueue)
|
||||
|
||||
sigChan := make(chan os.Signal, 1)
|
||||
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
|
||||
|
||||
go func() {
|
||||
if err := w.Run(worker.InterruptCh()); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}()
|
||||
|
||||
// Wait for shutdown signal
|
||||
<-sigChan
|
||||
log.Println("Shutting down worker...")
|
||||
w.Stop()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ProcessOrderWorkflow demonstrates multi-step workflow with activities
|
||||
func ProcessOrderWorkflow(ctx workflow.Context, orderID string) (string, error) {
|
||||
opts := workflow.ActivityOptions{
|
||||
StartToCloseTimeout: 5 * time.Minute,
|
||||
RetryPolicy: &temporal.RetryPolicy{
|
||||
InitialInterval: time.Second,
|
||||
BackoffCoefficient: 2.0,
|
||||
MaximumInterval: time.Minute,
|
||||
MaximumAttempts: 3,
|
||||
},
|
||||
}
|
||||
ctx = workflow.WithActivityOptions(ctx, opts)
|
||||
|
||||
// Step 1: Validate order
|
||||
var validated bool
|
||||
if err := workflow.ExecuteActivity(ctx, ValidateOrderActivity, orderID).Get(ctx, &validated); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !validated {
|
||||
return "", temporal.NewApplicationError("invalid order", "InvalidOrder")
|
||||
}
|
||||
|
||||
// Step 2: Process payment
|
||||
var paymentID string
|
||||
if err := workflow.ExecuteActivity(ctx, ProcessPaymentActivity, orderID).Get(ctx, &paymentID); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Step 3: Notify customer
|
||||
var notifyResult string
|
||||
if err := workflow.ExecuteActivity(ctx, NotifyCustomerActivity, orderID).Get(ctx, ¬ifyResult); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return paymentID, nil
|
||||
}
|
||||
|
||||
// GreeterWorkflow is a multi-step workflow
|
||||
func GreeterWorkflow(ctx workflow.Context, name string) (string, error) {
|
||||
opts := workflow.ActivityOptions{
|
||||
StartToCloseTimeout: 5 * time.Minute,
|
||||
}
|
||||
ctx = workflow.WithActivityOptions(ctx, opts)
|
||||
|
||||
var result string
|
||||
if err := workflow.ExecuteActivity(ctx, GreetActivity, name).Get(ctx, &result); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
@@ -21,6 +21,10 @@ spec:
|
||||
labels:
|
||||
app: api-gateway
|
||||
component: gateway
|
||||
# Required by the llm-serving-default-deny NetworkPolicy, which admits
|
||||
# only pods labelled llm-client=true (from any namespace) on port 8080.
|
||||
# Without it every upstream dial times out and dispatch returns 502.
|
||||
llm-client: "true"
|
||||
annotations:
|
||||
prometheus.io/scrape: "true"
|
||||
prometheus.io/port: "8080"
|
||||
|
||||
@@ -17,7 +17,7 @@ resources:
|
||||
# kustomize edit set image forgejo.riotpiao.com/rock/api-gateway=:<sha>
|
||||
images:
|
||||
- name: forgejo.riotpiao.com/rock/api-gateway
|
||||
newTag: REPLACE_WITH_FIRST_BUILD_SHA
|
||||
newTag: v0.1.1
|
||||
|
||||
commonLabels:
|
||||
app: api-gateway
|
||||
|
||||
@@ -34,15 +34,17 @@ spec:
|
||||
- to:
|
||||
- namespaceSelector:
|
||||
matchLabels:
|
||||
name: kube-system
|
||||
kubernetes.io/metadata.name: kube-system
|
||||
ports:
|
||||
- protocol: UDP
|
||||
port: 53
|
||||
- protocol: TCP
|
||||
port: 53
|
||||
# Allow to upstreams (LLM services in llm-serving namespace)
|
||||
- to:
|
||||
- namespaceSelector:
|
||||
matchLabels:
|
||||
name: llm-serving
|
||||
kubernetes.io/metadata.name: llm-serving
|
||||
ports:
|
||||
- protocol: TCP
|
||||
port: 80
|
||||
@@ -54,7 +56,7 @@ spec:
|
||||
- to:
|
||||
- namespaceSelector:
|
||||
matchLabels:
|
||||
name: llm-serving
|
||||
kubernetes.io/metadata.name: llm-serving
|
||||
ports:
|
||||
- protocol: TCP
|
||||
port: 8080
|
||||
@@ -62,7 +64,7 @@ spec:
|
||||
- to:
|
||||
- namespaceSelector:
|
||||
matchLabels:
|
||||
name: atlas
|
||||
kubernetes.io/metadata.name: atlas
|
||||
ports:
|
||||
- protocol: TCP
|
||||
port: 8080
|
||||
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
# Local development harness config with model registry for body-based dispatch.
|
||||
# This config is used for testing task 2.2 (body-based dispatch) and task 2.3 (error handling).
|
||||
# Every upstream points at the single stub server on loopback 127.0.0.1:9080.
|
||||
|
||||
routes: []
|
||||
|
||||
models:
|
||||
- name: "reasoning"
|
||||
address: "127.0.0.1:9080"
|
||||
path: "/v1/chat/completions"
|
||||
|
||||
- name: "ornith:35b"
|
||||
address: "127.0.0.1:9080"
|
||||
path: "/v1/chat/completions"
|
||||
|
||||
- name: "qwen2.5:3b-instruct"
|
||||
address: "127.0.0.1:9080"
|
||||
path: "/v1/chat/completions"
|
||||
|
||||
- name: "nomic-ai/nomic-embed-text-v2-moe"
|
||||
address: "127.0.0.1:9080"
|
||||
path: "/v1/embeddings"
|
||||
|
||||
- name: "BAAI/bge-reranker-base"
|
||||
address: "127.0.0.1:9080"
|
||||
path: "/v1/rerank"
|
||||
Reference in New Issue
Block a user