# GitLab AI Provider

A comprehensive TypeScript provider for integrating GitLab Duo AI capabilities with the Vercel AI SDK. This package enables seamless access to GitLab's AI-powered features including chat, agentic workflows, and tool calling through a unified interface.

## 🌟 Features

- **🤖 Multi-Provider Agentic Chat**: Native tool calling support via GitLab's AI Gateway (Anthropic & OpenAI)
- **🔄 Duo Workflow Service**: Server-side agentic loop with WebSocket streaming and dynamic model discovery
- **🔐 Multiple Authentication**: Support for OAuth, Personal Access Tokens, and OpenCode auth
- **🌐 Self-Hosted Support**: Works with both GitLab.com and self-hosted instances
- **🔧 Tool Support**: Native tool calling via Vercel AI SDK and MCP tools for workflows
- **🔍 Project Detection**: Automatic GitLab project detection from git remotes
- **💾 Smart Caching**: Project, token, and model discovery caching for optimal performance
- **🎯 Type-Safe**: Complete TypeScript definitions with Zod validation

## 📦 Installation

```bash
npm install gitlab-ai-provider
```

### Peer Dependencies

```bash
npm install @ai-sdk/provider @ai-sdk/provider-utils
```

## 🚀 Quick Start

### Basic Chat

```typescript
import { createGitLab } from 'gitlab-ai-provider';
import { generateText } from 'ai';

const gitlab = createGitLab({
  apiKey: process.env.GITLAB_TOKEN,
  instanceUrl: 'https://gitlab.com', // optional, defaults to gitlab.com
});

// All equivalent ways to create a chat model:
const model = gitlab('duo-chat'); // callable provider
const model2 = gitlab.chat('duo-chat'); // .chat() alias (recommended)
const model3 = gitlab.languageModel('duo-chat'); // explicit method

const { text } = await generateText({
  model: gitlab.chat('duo-chat'),
  prompt: 'Explain how to create a merge request in GitLab',
});

console.log(text);
```

### Agentic Chat with Tool Calling

```typescript
import { createGitLab } from 'gitlab-ai-provider';
import { generateText } from 'ai';

const gitlab = createGitLab({
  apiKey: process.env.GITLAB_TOKEN,
});

// Use agentic model for native tool calling support
const model = gitlab.agenticChat('duo-chat', {
  anthropicModel: 'claude-sonnet-4-20250514',
  maxTokens: 8192,
});

const { text } = await generateText({
  model,
  prompt: 'List all open merge requests in my project',
  tools: {
    // Your custom tools here
  },
});
```

### Model Variants

The provider automatically maps specific model IDs to their corresponding provider models (Anthropic or OpenAI) and routes requests to the appropriate AI Gateway proxy:

```typescript
import { createGitLab } from 'gitlab-ai-provider';
import { generateText } from 'ai';

const gitlab = createGitLab({
  apiKey: process.env.GITLAB_TOKEN,
});

// Anthropic Models (Claude)
const opusModel = gitlab.agenticChat('duo-chat-opus-4-5');
// Automatically uses: claude-opus-4-5-20251101

const sonnetModel = gitlab.agenticChat('duo-chat-sonnet-4-5');
// Automatically uses: claude-sonnet-4-5-20250929

const haikuModel = gitlab.agenticChat('duo-chat-haiku-4-5');
// Automatically uses: claude-haiku-4-5-20251001

// OpenAI Models (GPT-5)
const gpt5Model = gitlab.agenticChat('duo-chat-gpt-5-4');
// Automatically uses: gpt-5.4-2026-03-05

const gpt5MiniModel = gitlab.agenticChat('duo-chat-gpt-5-4-mini');
// Automatically uses: gpt-5.4-mini

const gpt5NanoModel = gitlab.agenticChat('duo-chat-gpt-5-4-nano');
// Automatically uses: gpt-5.4-nano

const codexModel = gitlab.agenticChat('duo-chat-gpt-5-codex');
// Automatically uses: gpt-5-codex

// You can still override with explicit providerModel option
const customModel = gitlab.agenticChat('duo-chat-opus-4-5', {
  providerModel: 'claude-sonnet-4-5-20250929', // Override mapping
});
```

**Available Model Mappings:**

| Model ID                 | Provider  | Backend Model                |
| ------------------------ | --------- | ---------------------------- |
| `duo-chat-fable-5-1`     | Anthropic | `claude-fable-5-1`           |
| `duo-chat-fable-5`       | Anthropic | `claude-fable-5`             |
| `duo-chat-opus-5-5`      | Anthropic | `claude-opus-5-5`            |
| `duo-chat-opus-5`        | Anthropic | `claude-opus-5`              |
| `duo-chat-opus-4-8`      | Anthropic | `claude-opus-4-8`            |
| `duo-chat-opus-4-7`      | Anthropic | `claude-opus-4-7`            |
| `duo-chat-opus-4-6`      | Anthropic | `claude-opus-4-6`            |
| `duo-chat-sonnet-5-5`    | Anthropic | `claude-sonnet-5-5`          |
| `duo-chat-sonnet-5`      | Anthropic | `claude-sonnet-5`            |
| `duo-chat-sonnet-4-6`    | Anthropic | `claude-sonnet-4-6`          |
| `duo-chat-opus-4-5`      | Anthropic | `claude-opus-4-5-20251101`   |
| `duo-chat-sonnet-4-5`    | Anthropic | `claude-sonnet-4-5-20250929` |
| `duo-chat-haiku-5-5`     | Anthropic | `claude-haiku-5-5`           |
| `duo-chat-haiku-4-5`     | Anthropic | `claude-haiku-4-5-20251001`  |
| `duo-chat-gpt-6-astra`   | OpenAI    | `gpt-6-astra`                |
| `duo-chat-gpt-6-1-sol`   | OpenAI    | `gpt-6.1-sol`                |
| `duo-chat-gpt-6-sol`     | OpenAI    | `gpt-6-sol`                  |
| `duo-chat-gpt-6-luna`    | OpenAI    | `gpt-6-luna`                 |
| `duo-chat-gpt-5-6-sol`   | OpenAI    | `gpt-5.6-sol`                |
| `duo-chat-gpt-5-6-terra` | OpenAI    | `gpt-5.6-terra`              |
| `duo-chat-gpt-5-6-luna`  | OpenAI    | `gpt-5.6-luna`               |
| `duo-chat-gpt-5-1`       | OpenAI    | `gpt-5.1-2025-11-13`         |
| `duo-chat-gpt-5-2`       | OpenAI    | `gpt-5.2-2025-12-11`         |
| `duo-chat-gpt-5-4`       | OpenAI    | `gpt-5.4-2026-03-05`         |
| `duo-chat-gpt-5-5`       | OpenAI    | `gpt-5.5-2026-04-23`         |
| `duo-chat-gpt-5-mini`    | OpenAI    | `gpt-5-mini-2025-08-07`      |
| `duo-chat-gpt-5-4-mini`  | OpenAI    | `gpt-5.4-mini`               |
| `duo-chat-gpt-5-4-nano`  | OpenAI    | `gpt-5.4-nano`               |
| `duo-chat-gpt-5-codex`   | OpenAI    | `gpt-5-codex`                |
| `duo-chat-gpt-5-2-codex` | OpenAI    | `gpt-5.2-codex`              |
| `duo-chat-gpt-5-3-codex` | OpenAI    | `gpt-5.3-codex`              |

For unmapped Anthropic model IDs, the provider defaults to `claude-sonnet-4-5-20250929`.

### OpenAI Models (GPT-5)

The provider supports OpenAI GPT-5 models through GitLab's AI Gateway proxy. OpenAI models are automatically detected based on the model ID and routed to the appropriate proxy endpoint.

```typescript
import { createGitLab } from 'gitlab-ai-provider';
import { generateText } from 'ai';

const gitlab = createGitLab({
  apiKey: process.env.GITLAB_TOKEN,
});

// GPT-5.4 - Most capable model
const { text } = await generateText({
  model: gitlab.agenticChat('duo-chat-gpt-5-4'),
  prompt: 'Explain GitLab CI/CD pipelines',
});

// GPT-5.4 Mini - Fast and efficient
const { text: quickResponse } = await generateText({
  model: gitlab.agenticChat('duo-chat-gpt-5-4-mini'),
  prompt: 'Summarize this code',
});

// GPT-5 Codex - Optimized for code
const { text: codeExplanation } = await generateText({
  model: gitlab.agenticChat('duo-chat-gpt-5-codex', {
    reasoningEffort: 'high',
  }),
  prompt: 'Refactor this function for better performance',
});
```

OpenAI models accept reasoning efforts from `none` through `max`, depending on the selected model. The effort is sent as `reasoning.effort` on the Responses API models and as `reasoning_effort` on the Chat Completions API models. Anthropic models accept `enabled` thinking with a token budget, `disabled` thinking, or `adaptive` thinking with an effort level:

```typescript
// budget-based thinking (e.g. Claude Sonnet 4.6)
const sonnet = gitlab.agenticChat('duo-chat-sonnet-4-6', {
  thinking: { type: 'enabled', budgetTokens: 4096 },
});

// adaptive thinking (required by effort-based models such as Claude Opus 4.8,
// which reject `thinking.type.enabled`)
const opus = gitlab.agenticChat('duo-chat-opus-4-8', {
  thinking: { type: 'adaptive', effort: 'high' },
});
```

Adaptive thinking is serialized as `thinking: { type: 'adaptive' }` plus a sibling `output_config: { effort }`. Both settings can also be passed per request under `providerOptions.gitlab`. Per-request values take precedence over the model defaults, which allows hosts such as OpenCode to implement model variants.

When Anthropic thinking is active (`enabled` or `adaptive`), the provider automatically drops `temperature` and omits any `top_p` outside the `[0.95, 1]` range, since Anthropic rejects those sampling parameters while extended thinking is engaged. Thinking blocks returned by Anthropic are surfaced as AI SDK `reasoning` parts (carrying the block signature under `providerOptions.gitlab`) and replayed unmodified on subsequent turns, as required during tool-use loops.

Anthropic requests use prompt caching: the system prompt and the final two messages carry `cache_control` breakpoints, so multi-turn sessions read the growing prefix back from cache. For one-shot or background calls whose context is never reused, pass `skipCache: true` under `providerOptions.gitlab` (or under the provider's configured name, such as `gitlab.agentic`) to omit the message-level breakpoints and avoid paying the cache-write premium for a cache nothing will read. The system prompt stays cached either way. `skipCache` defaults to `false`, and the `GitLabAnthropicProviderOptions` type documents the per-request Anthropic options:

```typescript
import type { GitLabAnthropicProviderOptions } from 'gitlab-ai-provider';

const { text: title } = await generateText({
  model: gitlab.agenticChat('duo-chat-sonnet-4-6'),
  prompt: 'Summarize this session in five words',
  providerOptions: {
    gitlab: { skipCache: true } satisfies GitLabAnthropicProviderOptions,
  },
});
```

**OpenAI Models with Tool Calling:**

```typescript
import { createGitLab } from 'gitlab-ai-provider';
import { generateText, tool } from 'ai';
import { z } from 'zod';

const gitlab = createGitLab({
  apiKey: process.env.GITLAB_TOKEN,
});

const { text, toolCalls } = await generateText({
  model: gitlab.agenticChat('duo-chat-gpt-5-1', {
    maxTokens: 4096,
  }),
  prompt: 'What is the weather in San Francisco?',
  tools: {
    getWeather: tool({
      description: 'Get the weather for a location',
      parameters: z.object({
        location: z.string().describe('The city name'),
      }),
      execute: async ({ location }) => {
        return { temperature: 72, condition: 'sunny', location };
      },
    }),
  },
});
```

### Agentic Chat with Feature Flags

You can pass feature flags to enable experimental features in GitLab's AI Gateway proxy:

```typescript
import { createGitLab } from 'gitlab-ai-provider';

// Option 1: Set feature flags globally for all agentic chat models
const gitlab = createGitLab({
  apiKey: process.env.GITLAB_TOKEN,
  featureFlags: {
    duo_agent_platform_agentic_chat: true,
    duo_agent_platform: true,
  },
});

const model = gitlab.agenticChat('duo-chat');

// Option 2: Set feature flags per model (overrides global flags)
const modelWithFlags = gitlab.agenticChat('duo-chat', {
  featureFlags: {
    duo_agent_platform_agentic_chat: true,
    duo_agent_platform: true,
    custom_feature_flag: false,
  },
});

// Option 3: Merge both (model-level flags take precedence)
const gitlab2 = createGitLab({
  featureFlags: {
    duo_agent_platform: true, // will be overridden
  },
});

const mergedModel = gitlab2.agenticChat('duo-chat', {
  featureFlags: {
    duo_agent_platform: false, // overrides provider-level
    duo_agent_platform_agentic_chat: true, // adds new flag
  },
});
```

### Duo Workflow Service (Server-Side Agentic)

The Duo Workflow Service provides a server-side agentic loop where GitLab drives the LLM and streams tool execution requests to the client via WebSocket. This enables powerful agentic workflows with dynamic model discovery and MCP tool integration.

**Requirements:**

- GitLab Ultimate with Duo Enterprise add-on
- GitLab 18.4+ (18.5+ for pinned model support)

```typescript
import { createGitLab } from 'gitlab-ai-provider';
import { streamText } from 'ai';

const gitlab = createGitLab({
  apiKey: process.env.GITLAB_TOKEN,
  instanceUrl: 'https://gitlab.com', // or your self-hosted instance
});

// Use the duo-workflow model for server-side agentic workflows
const model = gitlab.workflowChat('duo-workflow', {
  // Optional: Specify root namespace for model discovery
  rootNamespaceId: 'gid://gitlab/Group/12345',

  // Optional: Provide MCP tools for the workflow
  mcpTools: [
    {
      name: 'searchCode',
      description: 'Search for code in the repository',
      inputSchema: JSON.stringify({
        type: 'object',
        properties: {
          query: { type: 'string' },
        },
        required: ['query'],
      }),
    },
  ],

  // Optional: Pre-approve tools for automatic execution
  preapprovedTools: ['read_file', 'write_file'],
});

// Stream the workflow execution
const result = await streamText({
  model,
  prompt: 'Refactor the authentication module to use JWT tokens',
});

for await (const chunk of result.textStream) {
  process.stdout.write(chunk);
}
```

**Dynamic Model Discovery:**

The workflow service automatically discovers available models for your namespace and respects admin-pinned models:

```typescript
const model = gitlab.workflowChat('duo-workflow', {
  rootNamespaceId: 'gid://gitlab/Group/12345',

  // Optional: Callback for interactive model selection
  onSelectModel: async (models) => {
    // Present model picker to user
    console.log('Available models:', models);
    // Return selected model ref or null for default
    return models[0].ref;
  },
});
```

**Model Selection Priority:**

1. Admin-pinned model (always used if set)
2. User-selected model (via `onSelectModel` callback)
3. Namespace default model

**Built-in Tool Support:**

The workflow service automatically maps DWS built-in tools to consumer tool names:

| DWS Tool          | Consumer Tool | Description                   |
| ----------------- | ------------- | ----------------------------- |
| `runReadFile`     | `read`        | Read file contents            |
| `runWriteFile`    | `write`       | Write file contents           |
| `runEditFile`     | `edit`        | Edit file with old/new string |
| `runShellCommand` | `bash`        | Execute shell command         |
| `runCommand`      | `bash`        | Execute structured command    |
| `runGitCommand`   | `bash`        | Execute git command           |
| `listDirectory`   | `read`        | List directory contents       |
| `findFiles`       | `glob`        | Find files by pattern         |
| `grep`            | `grep`        | Search file contents          |
| `mkdir`           | `bash`        | Create directory              |
| `runHTTPRequest`  | `bash`        | Execute HTTP request          |

**Server-Side Tool Visibility:**

When DWS executes tools on the server (e.g., MCP tools like Linear or Context7 that are configured in the agent's catalog), the provider automatically surfaces these as structured AI SDK stream events (`tool-input-start`, `tool-call`, `tool-result`) with `providerExecuted: true` and `dynamic: true`. This allows host applications to display server-side tool calls in their UI alongside locally-executed tools.

Server-side tool events are deduplicated across turns — the provider tracks emitted checkpoint tool entries in the persistent session state so that cumulative DWS checkpoints don't re-emit tools from previous turns. Tools that were already handled via `tool-request` (client-executed tools) are also excluded from checkpoint emission to avoid duplicates.

Since server-side MCP tools are not registered in the host's tool registry, the `dynamic: true` flag ensures the AI SDK's `parseToolCall` bypasses tool registry validation via the `parseProviderExecutedDynamicToolCall` path.

**Tool Call Approval:**

The workflow model supports an approval flow for tool execution. When DWS requires approval before executing a tool, it sends a `TOOL_CALL_APPROVAL_REQUIRED` checkpoint. The provider exposes this via the `approvalHandler` callback:

```typescript
const model = gitlab.workflowChat('duo-workflow');

// Set approval handler — called when DWS requests tool execution approval
model.approvalHandler = async (tools) => {
  // tools: Array<{ name: string, args: string }>
  console.log(
    'Approval requested for:',
    tools.map((t) => t.name)
  );
  return { approved: true }; // or { approved: false }
};

// Pre-approve specific tools to skip approval prompts
model.sessionPreapprovedTools = ['read', 'glob', 'grep'];
```

Approved tools are tracked per-session. When the user approves a tool via "allow always", the provider automatically pre-approves it on subsequent DWS reconnections within the same session.

**Workflow Options:**

```typescript
interface GitLabWorkflowOptions {
  // Root namespace ID for model discovery and token scoping
  rootNamespaceId?: string;

  // GitLab project ID (numeric or path)
  projectId?: string;

  // Plain numeric GitLab namespace ID (sent as x-gitlab-namespace-id)
  namespaceId?: string;

  // MCP tool definitions to expose to the workflow
  mcpTools?: McpToolDefinition[];

  // Client capabilities to advertise
  clientCapabilities?: string[]; // Default: ['shell_command']

  // Tool names pre-approved for execution without confirmation
  preapprovedTools?: string[];

  // Additional context items for conversation history
  additionalContext?: AdditionalContext[];

  // Feature flags for the workflow
  featureFlags?: Record<string, boolean>;

  // Working directory for project auto-detection
  workingDirectory?: string; // Default: process.cwd()

  // Flow configuration for agent behavior
  flowConfig?: unknown;

  // Flow configuration schema version
  flowConfigSchemaVersion?: string;

  // Callback for interactive model selection
  onSelectModel?: (models: AiModel[]) => Promise<string | null | undefined>;
}
```

**Provider-Level Workflow Options (Self-Managed Instances):**

Hosts that create models through `provider(modelId)` / `languageModel(modelId)` (OpenCode does) cannot pass `GitLabWorkflowOptions` per call. Set them once on the provider with `workflowOptions` instead. Per-call options passed to `workflowChat()` take precedence; per-call `undefined` values are ignored and `featureFlags` are merged.

The Duo Agent Platform needs a namespace to create the workflow and open the WebSocket. On GitLab.com this usually comes from the user's default Duo namespace preference. On a self-managed instance without that preference, the request is refused with `403 Missing default GitLab Duo namespace user preference` unless a namespace is supplied. Workflow creation uses `namespaceId` when set, otherwise the project detected from the git remote. The WebSocket namespace is resolved in this order:

1. `workflowOptions.namespaceId`: the **plain numeric** namespace (group) id, on any instance.
2. Self-managed and Dedicated instances only: the namespace of the project detected from the git remote in `workingDirectory`.
3. Otherwise none is sent and the user's default Duo namespace applies. GitLab.com never uses the detected namespace, so the default preference keeps working there.

```typescript
const gitlab = createGitLab({
  instanceUrl: 'https://gitlab.example.com',
  apiKey: process.env.GITLAB_TOKEN,
  workflowOptions: {
    namespaceId: '12', // numeric id, not 'gid://gitlab/Group/12'
  },
});

const model = gitlab('duo-workflow-sonnet-4-6'); // picks up workflowOptions
```

The equivalent OpenCode configuration (`opencode.json`):

```json
{
  "provider": {
    "gitlab": {
      "options": {
        "instanceUrl": "https://gitlab.example.com",
        "workflowOptions": { "namespaceId": "12" }
      }
    }
  }
}
```

**Environment Variables:**

| Variable              | Description                                 | Default              |
| --------------------- | ------------------------------------------- | -------------------- |
| `GITLAB_INSTANCE_URL` | GitLab instance URL                         | `https://gitlab.com` |
| `GITLAB_TOKEN`        | GitLab Personal Access Token or OAuth token | -                    |

**Model Cache:**

The workflow service caches model discovery results and user selections in `~/.cache/opencode/gitlab-workflow-model-cache.json` (or `$XDG_CACHE_HOME/opencode/...`). The cache is keyed by workspace directory and instance URL, with a 10-minute TTL for discovery data.

## 🔑 Authentication

### Personal Access Token

```typescript
const gitlab = createGitLab({
  apiKey: 'glpat-xxxxxxxxxxxxxxxxxxxx',
});
```

### Environment Variable

```bash
export GITLAB_TOKEN=glpat-xxxxxxxxxxxxxxxxxxxx
```

```typescript
const gitlab = createGitLab(); // Automatically uses GITLAB_TOKEN
```

### OAuth (OpenCode Auth)

The provider automatically detects and uses OpenCode authentication if available:

```typescript
const gitlab = createGitLab({
  instanceUrl: 'https://gitlab.com',
  // OAuth tokens are loaded from ~/.opencode/auth.json
});
```

### Custom Headers

```typescript
const gitlab = createGitLab({
  apiKey: 'your-token',
  headers: {
    'X-Custom-Header': 'value',
  },
});
```

### AI Gateway Headers

Custom headers can be sent to GitLab's AI Gateway (Anthropic/OpenAI proxy) for traffic identification and routing. By default, the provider sends `User-Agent: gitlab-ai-provider/{version}`.

```typescript
// Provider-level headers (apply to all agentic models)
const gitlab = createGitLab({
  apiKey: process.env.GITLAB_TOKEN,
  aiGatewayHeaders: {
    'X-Custom-Routing': 'premium-tier',
  },
});

// Model-level headers (override provider-level)
const model = gitlab.agenticChat('duo-chat-opus-4-5', {
  aiGatewayHeaders: {
    'X-Request-Priority': 'high',
  },
});
```

**Header Precedence (lowest to highest):**

1. Default headers (`User-Agent: gitlab-ai-provider/{version}`)
2. Provider-level `aiGatewayHeaders`
3. Model-level `aiGatewayHeaders`

## 🏗️ Architecture

### Core Components

#### 1. **GitLabProvider**

Main provider factory that creates language models with different capabilities.

```typescript
interface GitLabProvider {
  (modelId: string): LanguageModelV2;
  languageModel(modelId: string): LanguageModelV2;
  agenticChat(modelId: string, options?: GitLabAgenticOptions): GitLabAgenticLanguageModel;
  workflowChat(modelId: string, options?: GitLabWorkflowOptions): GitLabWorkflowLanguageModel;
}
```

#### 2. **GitLabAnthropicLanguageModel**

Provides native tool calling through GitLab's Anthropic proxy.

- Uses Claude models via `https://cloud.gitlab.com/ai/v1/proxy/anthropic/`
- Automatic token refresh and retry logic
- Direct access token management
- Supports all Anthropic tool calling features

#### 3. **GitLabOpenAILanguageModel**

Provides native tool calling through GitLab's OpenAI proxy.

- Uses GPT-5 models via `https://cloud.gitlab.com/ai/v1/proxy/openai/`
- Automatic token refresh and retry logic
- Direct access token management
- Supports all OpenAI tool calling features including parallel tool calls

#### 4. **GitLabWorkflowLanguageModel**

Provides server-side agentic execution through GitLab Duo Workflow Service.

- WebSocket-based bidirectional communication with DWS
- Dynamic model discovery via GraphQL (`aiChatAvailableModels`)
- Automatic model selection (pinned → user-selected → default)
- Built-in tool mapping and MCP tool support
- Server-side tool visibility — DWS-executed MCP tools are surfaced as structured AI SDK events
- Tool call approval flow with per-session pre-approval tracking
- Per-stream state isolation for concurrent requests
- Dual heartbeat (WebSocket ping + JSON heartbeat)

### Supporting Utilities

#### GitLabProjectDetector

Automatically detects GitLab projects from git remotes.

```typescript
const detector = new GitLabProjectDetector({
  instanceUrl: 'https://gitlab.com',
  getHeaders: () => ({ Authorization: `Bearer ${token}` }),
});

const project = await detector.detectProject(process.cwd());
// Returns: { id: 12345, path: 'group/project', namespaceId: 67890 }
```

#### GitLabProjectCache

Caches project information with TTL.

```typescript
const cache = new GitLabProjectCache(5 * 60 * 1000); // 5 minutes
cache.set('key', project);
const cached = cache.get('key');
```

#### GitLabOAuthManager

Manages OAuth token lifecycle.

```typescript
const oauthManager = new GitLabOAuthManager();

// Exchange authorization code
const tokens = await oauthManager.exchangeAuthorizationCode({
  instanceUrl: 'https://gitlab.com',
  code: 'auth-code',
  codeVerifier: 'verifier',
});

// Refresh tokens
const refreshed = await oauthManager.refreshIfNeeded(tokens);
```

#### GitLabDirectAccessClient

Manages direct access tokens for Anthropic proxy.

```typescript
const client = new GitLabDirectAccessClient({
  instanceUrl: 'https://gitlab.com',
  getHeaders: () => ({ Authorization: `Bearer ${token}` }),
});

const directToken = await client.getDirectAccessToken();
// Returns: { token: 'xxx', headers: {...}, expiresAt: 123456 }
```

#### GitLabModelDiscovery

Discovers available workflow models via GraphQL. Scope by `projectId`
(preferred — works for both group- and user-namespace projects) or
`rootNamespaceId` (group namespaces only, supported on older GitLab
instances). A bare string is treated as `rootNamespaceId` for backwards
compatibility. When scoped by `projectId` with a `rootNamespaceId` also
supplied, discovery falls back to the namespace-scoped query if the
`projectId` query fails (e.g. on GitLab versions predating `projectId`
support).

```typescript
const discovery = new GitLabModelDiscovery({
  instanceUrl: 'https://gitlab.com',
  getHeaders: () => ({ Authorization: `Bearer ${token}` }),
});

const models = await discovery.discover({
  projectId: 'gid://gitlab/Project/12345',
  rootNamespaceId: 'gid://gitlab/Group/6789', // optional fallback
});
// Returns: { defaultModel, selectableModels, pinnedModel, modelSwitchingEnabled }

const effectiveRef = await discovery.getEffectiveModelRef(
  { projectId: 'gid://gitlab/Project/12345' },
  'claude_sonnet_4_6'
);
```

#### GitLabModelCache

Persists model discovery results and user selections.

```typescript
const cache = new GitLabModelCache('/workspace/path', 'https://gitlab.com');

cache.saveDiscovery(discoveredModels);
cache.saveSelection('claude_sonnet_4_6', 'Claude Sonnet 4.6');

const selectedRef = cache.getSelectedModelRef();
const discovery = cache.getDiscovery();
```

#### GitLabWorkflowClient

Low-level WebSocket client for DWS communication.

```typescript
const client = new GitLabWorkflowClient();

await client.connect(
  {
    instanceUrl: 'https://gitlab.com',
    modelRef: 'claude_sonnet_4_6',
    headers: { Authorization: `Bearer ${token}` },
    projectId: 'my-group/my-project',
  },
  (event) => {
    if (event.type === 'checkpoint') {
      console.log('Checkpoint:', event.data);
    } else if (event.type === 'tool-request') {
      console.log('Tool request:', event.data);
    }
  }
);

client.sendStartRequest({ workflowID: '123', goal: 'Refactor code', ... });
client.sendActionResponse('request-id', 'tool result');
client.stop();
```

#### GitLabWorkflowTokenClient

Manages DWS token lifecycle and workflow creation.

```typescript
const tokenClient = new GitLabWorkflowTokenClient({
  instanceUrl: 'https://gitlab.com',
  getHeaders: () => ({ Authorization: `Bearer ${token}` }),
});

const token = await tokenClient.getToken('chat', 'gid://gitlab/Group/12345');
const workflowId = await tokenClient.createWorkflow('Refactor authentication', {
  projectId: 'my-group/my-project',
});
```

## 📚 API Reference

### Provider Configuration

```typescript
interface GitLabProviderSettings {
  instanceUrl?: string; // Default: 'https://gitlab.com'
  apiKey?: string; // PAT or OAuth access token
  refreshToken?: string; // OAuth refresh token
  name?: string; // Provider name prefix
  headers?: Record<string, string>; // Custom headers for GitLab API
  aiGatewayHeaders?: Record<string, string>; // Custom headers for AI Gateway proxy
  fetch?: typeof fetch; // Custom fetch implementation
  aiGatewayUrl?: string; // AI Gateway URL (default: 'https://cloud.gitlab.com')
  directAccessCacheDir?: string; // Private local directory for shared credentials; '' disables
  workflowOptions?: GitLabWorkflowOptions; // Defaults for duo-workflow-* models (e.g. namespaceId)
}
```

### Environment Variables

| Variable                         | Description                                                  | Default                    |
| -------------------------------- | ------------------------------------------------------------ | -------------------------- |
| `GITLAB_TOKEN`                   | GitLab Personal Access Token or OAuth token                  | -                          |
| `GITLAB_INSTANCE_URL`            | GitLab instance URL                                          | `https://gitlab.com`       |
| `GITLAB_AI_GATEWAY_URL`          | AI Gateway URL for Anthropic proxy                           | `https://cloud.gitlab.com` |
| `GITLAB_DIRECT_ACCESS_CACHE_DIR` | Private local directory for shared direct-access credentials | Disabled                   |

### Shared direct-access credentials (POSIX Node.js, opt-in)

Reuse short-lived AI Gateway credentials across processes by setting the provider's
`directAccessCacheDir` option or `GITLAB_DIRECT_ACCESS_CACHE_DIR` to an absolute,
private local directory (Linux/macOS only):

```typescript
const gitlab = createGitLab({
  apiKey: process.env.GITLAB_TOKEN,
  directAccessCacheDir: '/home/alice/.local/share/gitlab-ai-provider/credentials',
});
```

The effective setting is `directAccessCacheDir ?? process.env.GITLAB_DIRECT_ACCESS_CACHE_DIR`:
an explicit option takes precedence, and `''` disables disk caching even when the
environment variable is set. By default, nothing is written to disk.

Valid in-memory credentials remain the fast path; permissions are checked only on
disk access, not on every token use. A disk hit keeps the entry's original expiry
when loaded into memory; it does not start a new 25-minute lifetime. Concurrent
refreshes for the same scope are serialized. Lock acquisition can wait approximately
60 seconds, followed by up to 30 seconds for refresh. The 45-second stale-lock
threshold is required for recovery after a crashed owner; callers should budget
for the combined wait and refresh.

**Follow-up limitation:** failed requests, including HTTP 429 and 5xx responses,
are not negatively cached. During an outage, waiting processes can therefore make
successive refresh attempts; this cache is not a cross-process backoff mechanism.
Expired credentials are never used as an outage fallback.

<details>
<summary>Permissions, expiry, pruning, and lease caveats</summary>

The directory is created with mode `0700`; existing directories must be owned by
the current user and inaccessible to group/other users. Token files use mode
`0600`. Treat the directory as credential storage: never commit it, attach it to
a review packet, or put it on a shared filesystem. Windows is not supported.

Use a directory under your private home/data directory whose ancestors cannot be
renamed or modified by other users. Final-component symlink checks do not make an
attacker-controlled parent directory safe. Directory identity is rechecked after
a refresh as a best-effort replacement check, not as protection against an
attacker who can continuously replace path components. Cache corruption or unsafe
permissions encountered during disk access fail closed.

Entries are separated by GitLab URL, gateway URL, request headers (including the
OAuth/PAT credential), and feature flags, and expire after at most 25 minutes.
Credential rotation therefore creates a new entry. Each refresh performs
best-effort expired-entry pruning across scopes, examining at most 32 candidates
and attempting a nonblocking lock for each candidate before removal. Busy entries
are skipped; this bounded cleanup is not a guarantee that every expired entry is
removed immediately.

One successful token request is needed to seed the cache; it cannot recover a
credential held only in another process's memory. Existing processes do not
automatically start using this opt-in feature. Valid disk hits do not acquire the
refresh lock. A refresh lock abandoned by a crashed process is recovered by
`proper-lockfile` after its heartbeat has been stale for 45 seconds. Do not
manually remove an active lock or run clients with different lease settings for
the same cache.

Use a local filesystem; a process pause or filesystem stall exceeding the lease
can compromise it, in which case the refresh fails instead of deliberately
publishing after loss of ownership. This is lease-based coordination, not an
OS-enforced fencing guarantee. Refresh fetch and response-body consumption are
bounded to 30 seconds and aborted on timeout. A custom fetch must honor
`AbortSignal` to release its own resources; even if it ignores abort, a late result
cannot publish a cache entry.

</details>

### Agentic Chat Options

```typescript
interface GitLabAgenticOptions {
  providerModel?: string; // Override the backend model (e.g., 'claude-sonnet-4-5-20250929' or 'gpt-5.1-2025-11-13')
  maxTokens?: number; // Default: 8192
  reasoningEffort?: 'none' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh' | 'max'; // OpenAI models (Responses + Chat Completions)
  thinking?: // Anthropic models
    | { type: 'disabled' }
    | { type: 'enabled'; budgetTokens: number }
    | { type: 'adaptive'; effort: 'low' | 'medium' | 'high' | 'xhigh' | 'max' };
  featureFlags?: Record<string, boolean>; // GitLab feature flags
  aiGatewayHeaders?: Record<string, string>; // Custom headers for AI Gateway proxy (per-model)
}
```

### Per-Request Anthropic Options

Passed under `providerOptions.gitlab` (or the provider's configured name, such as `gitlab.agentic`):

```typescript
interface GitLabAnthropicProviderOptions {
  thinking?: GitLabThinkingConfig; // Overrides the model-level thinking default
  skipCache?: boolean; // Default: false. Omits message-level cache breakpoints; the system prompt stays cached
}
```

**Note:** The `providerModel` option allows you to override the automatically mapped model. The provider will validate that the override is compatible with the model ID's provider (e.g., you cannot use an OpenAI model with a `duo-chat-opus-*` model ID).

### Error Handling

```typescript
import { GitLabError } from 'gitlab-ai-provider';

try {
  const result = await generateText({ model, prompt });
} catch (error) {
  if (error instanceof GitLabError) {
    if (error.isAuthError()) {
      console.error('Authentication failed');
    } else if (error.isRateLimitError()) {
      console.error('Rate limit exceeded');
    } else if (error.isServerError()) {
      console.error('Server error:', error.statusCode);
    }
  }
}
```

## 🔧 Development

### Build

```bash
npm run build          # Build once
npm run build:watch    # Build in watch mode
```

### Testing

```bash
npm test                    # Run all tests
npm run test:watch           # Run tests in watch mode
npm run test:cache-processes # Offline cross-process cache check (requires npm run build)
npm run test:cache-crash     # Offline crash recovery check (~45 seconds; requires npm run build)
```

### Code Quality

```bash
npm run lint          # Lint code
npm run lint:fix      # Lint and auto-fix
npm run format        # Format code
npm run format:check  # Check formatting
npm run type-check    # TypeScript type checking
```

### Project Structure

```
gitlab-ai-provider/
├── src/
│   ├── index.ts                            # Main exports
│   ├── gitlab-provider.ts                  # Provider factory
│   ├── gitlab-anthropic-language-model.ts  # Anthropic/Claude model
│   ├── gitlab-openai-language-model.ts     # OpenAI/GPT model
│   ├── gitlab-workflow-language-model.ts   # Workflow/DWS model
│   ├── gitlab-workflow-client.ts           # WebSocket client for DWS
│   ├── gitlab-workflow-token-client.ts     # DWS token management
│   ├── gitlab-workflow-builtins.ts         # Built-in tool mapping
│   ├── gitlab-workflow-types.ts            # DWS protocol types
│   ├── gitlab-model-discovery.ts           # GraphQL model discovery
│   ├── gitlab-model-cache.ts               # Model selection cache
│   ├── model-mappings.ts                   # Model ID mappings
│   ├── gitlab-direct-access.ts             # Direct access tokens
│   ├── gitlab-oauth-manager.ts             # OAuth management
│   ├── gitlab-oauth-types.ts               # OAuth types
│   ├── gitlab-project-detector.ts          # Project detection
│   ├── gitlab-project-cache.ts             # Project caching
│   ├── gitlab-api-types.ts                 # API types
│   └── gitlab-error.ts                     # Error handling
├── tests/                                  # Test files (300 tests)
├── dist/                                   # Build output
├── package.json
├── tsconfig.json
├── tsup.config.ts
└── vitest.config.ts
```

## 📝 Code Style

- **Imports**: Named imports, organized by external → internal → types
- **Formatting**: Single quotes, semicolons, 100 char line width, 2 space indent
- **Types**: Interfaces for public APIs, Zod schemas for runtime validation
- **Naming**: camelCase (variables/functions), PascalCase (classes/types), kebab-case (files)
- **Exports**: Named exports only (no default exports)
- **Comments**: JSDoc for public APIs with @param/@returns

---

## Assistant

## 🤝 Contributing

Contributions are welcome! Please see our [Contributing Guide](https://gitlab.com/vglafirov/gitlab-ai-provider/-/blob/main/CONTRIBUTING.md) for detailed guidelines on:

- Code style and conventions
- Development workflow
- Testing requirements
- Submitting merge requests
- Developer Certificate of Origin and License

**Quick Start for Contributors**:

1. **Commit Messages**: Use conventional commits format

```

feat(scope): add new feature
fix(scope): fix bug
docs(scope): update documentation

```

2. **Code Quality**: Ensure all checks pass

```bash
npm run lint
npm run type-check
npm test
```

3. **Testing**: Add tests for new features

## 🔗 Links

- [GitLab Repository](https://gitlab.com/vglafirov/gitlab-ai-provider)
- [npm Package](https://www.npmjs.com/package/gitlab-ai-provider)
- [Issue Tracker](https://gitlab.com/vglafirov/gitlab-ai-provider/-/issues)
- [Contributing Guide](https://gitlab.com/vglafirov/gitlab-ai-provider/-/blob/main/CONTRIBUTING.md)
- [Changelog](https://gitlab.com/vglafirov/gitlab-ai-provider/-/blob/main/CHANGELOG.md)
- [Agent Guidelines](https://gitlab.com/vglafirov/gitlab-ai-provider/-/blob/main/AGENTS.md)

## 🙏 Acknowledgments

This project is built on top of:

- [Vercel AI SDK](https://sdk.vercel.ai/)
- [Anthropic SDK](https://github.com/anthropics/anthropic-sdk-typescript)
- [OpenAI SDK](https://github.com/openai/openai-node)
- [GitLab Duo](https://about.gitlab.com/gitlab-duo/)

---

**Made with ❤️ for the OpenCode community**

---
