mirror of
https://github.com/coder/coder.git
synced 2025-07-12 00:14:10 +00:00
feat: add database tables and API routes for agentic chat feature (#17570)
Backend portion of experimental `AgenticChat` feature: - Adds database tables for chats and chat messages - Adds functionality to stream messages from LLM providers using `kylecarbs/aisdk-go` - Adds API routes with relevant functionality (list, create, update chats, insert chat message) - Adds experiment `codersdk.AgenticChat` --------- Co-authored-by: Kyle Carberry <kyle@carberry.com>
This commit is contained in:
153
codersdk/chat.go
Normal file
153
codersdk/chat.go
Normal file
@ -0,0 +1,153 @@
|
||||
package codersdk
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/kylecarbs/aisdk-go"
|
||||
"golang.org/x/xerrors"
|
||||
)
|
||||
|
||||
// CreateChat creates a new chat.
|
||||
func (c *Client) CreateChat(ctx context.Context) (Chat, error) {
|
||||
res, err := c.Request(ctx, http.MethodPost, "/api/v2/chats", nil)
|
||||
if err != nil {
|
||||
return Chat{}, xerrors.Errorf("execute request: %w", err)
|
||||
}
|
||||
if res.StatusCode != http.StatusCreated {
|
||||
return Chat{}, ReadBodyAsError(res)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
var chat Chat
|
||||
return chat, json.NewDecoder(res.Body).Decode(&chat)
|
||||
}
|
||||
|
||||
type Chat struct {
|
||||
ID uuid.UUID `json:"id" format:"uuid"`
|
||||
CreatedAt time.Time `json:"created_at" format:"date-time"`
|
||||
UpdatedAt time.Time `json:"updated_at" format:"date-time"`
|
||||
Title string `json:"title"`
|
||||
}
|
||||
|
||||
// ListChats lists all chats.
|
||||
func (c *Client) ListChats(ctx context.Context) ([]Chat, error) {
|
||||
res, err := c.Request(ctx, http.MethodGet, "/api/v2/chats", nil)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("execute request: %w", err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
if res.StatusCode != http.StatusOK {
|
||||
return nil, ReadBodyAsError(res)
|
||||
}
|
||||
|
||||
var chats []Chat
|
||||
return chats, json.NewDecoder(res.Body).Decode(&chats)
|
||||
}
|
||||
|
||||
// Chat returns a chat by ID.
|
||||
func (c *Client) Chat(ctx context.Context, id uuid.UUID) (Chat, error) {
|
||||
res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/chats/%s", id), nil)
|
||||
if err != nil {
|
||||
return Chat{}, xerrors.Errorf("execute request: %w", err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
if res.StatusCode != http.StatusOK {
|
||||
return Chat{}, ReadBodyAsError(res)
|
||||
}
|
||||
var chat Chat
|
||||
return chat, json.NewDecoder(res.Body).Decode(&chat)
|
||||
}
|
||||
|
||||
// ChatMessages returns the messages of a chat.
|
||||
func (c *Client) ChatMessages(ctx context.Context, id uuid.UUID) ([]ChatMessage, error) {
|
||||
res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/chats/%s/messages", id), nil)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("execute request: %w", err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
if res.StatusCode != http.StatusOK {
|
||||
return nil, ReadBodyAsError(res)
|
||||
}
|
||||
var messages []ChatMessage
|
||||
return messages, json.NewDecoder(res.Body).Decode(&messages)
|
||||
}
|
||||
|
||||
type ChatMessage = aisdk.Message
|
||||
|
||||
type CreateChatMessageRequest struct {
|
||||
Model string `json:"model"`
|
||||
Message ChatMessage `json:"message"`
|
||||
Thinking bool `json:"thinking"`
|
||||
}
|
||||
|
||||
// CreateChatMessage creates a new chat message and streams the response.
|
||||
// If the provided message has a conflicting ID with an existing message,
|
||||
// it will be overwritten.
|
||||
func (c *Client) CreateChatMessage(ctx context.Context, id uuid.UUID, req CreateChatMessageRequest) (<-chan aisdk.DataStreamPart, error) {
|
||||
res, err := c.Request(ctx, http.MethodPost, fmt.Sprintf("/api/v2/chats/%s/messages", id), req)
|
||||
defer func() {
|
||||
if res != nil && res.Body != nil {
|
||||
_ = res.Body.Close()
|
||||
}
|
||||
}()
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("execute request: %w", err)
|
||||
}
|
||||
if res.StatusCode != http.StatusOK {
|
||||
return nil, ReadBodyAsError(res)
|
||||
}
|
||||
nextEvent := ServerSentEventReader(ctx, res.Body)
|
||||
|
||||
wc := make(chan aisdk.DataStreamPart, 256)
|
||||
go func() {
|
||||
defer close(wc)
|
||||
defer res.Body.Close()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
default:
|
||||
sse, err := nextEvent()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if sse.Type != ServerSentEventTypeData {
|
||||
continue
|
||||
}
|
||||
var part aisdk.DataStreamPart
|
||||
b, ok := sse.Data.([]byte)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
err = json.Unmarshal(b, &part)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case wc <- part:
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
return wc, nil
|
||||
}
|
||||
|
||||
func (c *Client) DeleteChat(ctx context.Context, id uuid.UUID) error {
|
||||
res, err := c.Request(ctx, http.MethodDelete, fmt.Sprintf("/api/v2/chats/%s", id), nil)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("execute request: %w", err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
if res.StatusCode != http.StatusNoContent {
|
||||
return ReadBodyAsError(res)
|
||||
}
|
||||
return nil
|
||||
}
|
@ -383,6 +383,7 @@ type DeploymentValues struct {
|
||||
DisablePasswordAuth serpent.Bool `json:"disable_password_auth,omitempty" typescript:",notnull"`
|
||||
Support SupportConfig `json:"support,omitempty" typescript:",notnull"`
|
||||
ExternalAuthConfigs serpent.Struct[[]ExternalAuthConfig] `json:"external_auth,omitempty" typescript:",notnull"`
|
||||
AI serpent.Struct[AIConfig] `json:"ai,omitempty" typescript:",notnull"`
|
||||
SSHConfig SSHConfig `json:"config_ssh,omitempty" typescript:",notnull"`
|
||||
WgtunnelHost serpent.String `json:"wgtunnel_host,omitempty" typescript:",notnull"`
|
||||
DisableOwnerWorkspaceExec serpent.Bool `json:"disable_owner_workspace_exec,omitempty" typescript:",notnull"`
|
||||
@ -2660,6 +2661,15 @@ Write out the current server config as YAML to stdout.`,
|
||||
Value: &c.Support.Links,
|
||||
Hidden: false,
|
||||
},
|
||||
{
|
||||
// Env handling is done in cli.ReadAIProvidersFromEnv
|
||||
Name: "AI",
|
||||
Description: "Configure AI providers.",
|
||||
YAML: "ai",
|
||||
Value: &c.AI,
|
||||
// Hidden because this is experimental.
|
||||
Hidden: true,
|
||||
},
|
||||
{
|
||||
// Env handling is done in cli.ReadGitAuthFromEnvironment
|
||||
Name: "External Auth Providers",
|
||||
@ -3081,6 +3091,21 @@ Write out the current server config as YAML to stdout.`,
|
||||
return opts
|
||||
}
|
||||
|
||||
type AIProviderConfig struct {
|
||||
// Type is the type of the API provider.
|
||||
Type string `json:"type" yaml:"type"`
|
||||
// APIKey is the API key to use for the API provider.
|
||||
APIKey string `json:"-" yaml:"api_key"`
|
||||
// Models is the list of models to use for the API provider.
|
||||
Models []string `json:"models" yaml:"models"`
|
||||
// BaseURL is the base URL to use for the API provider.
|
||||
BaseURL string `json:"base_url" yaml:"base_url"`
|
||||
}
|
||||
|
||||
type AIConfig struct {
|
||||
Providers []AIProviderConfig `json:"providers,omitempty" yaml:"providers,omitempty"`
|
||||
}
|
||||
|
||||
type SupportConfig struct {
|
||||
Links serpent.Struct[[]LinkConfig] `json:"links" typescript:",notnull"`
|
||||
}
|
||||
@ -3303,6 +3328,7 @@ const (
|
||||
ExperimentWebPush Experiment = "web-push" // Enables web push notifications through the browser.
|
||||
ExperimentDynamicParameters Experiment = "dynamic-parameters" // Enables dynamic parameters when creating a workspace.
|
||||
ExperimentWorkspacePrebuilds Experiment = "workspace-prebuilds" // Enables the new workspace prebuilds feature.
|
||||
ExperimentAgenticChat Experiment = "agentic-chat" // Enables the new agentic AI chat feature.
|
||||
)
|
||||
|
||||
// ExperimentsSafe should include all experiments that are safe for
|
||||
@ -3517,6 +3543,32 @@ func (c *Client) SSHConfiguration(ctx context.Context) (SSHConfigResponse, error
|
||||
return sshConfig, json.NewDecoder(res.Body).Decode(&sshConfig)
|
||||
}
|
||||
|
||||
type LanguageModelConfig struct {
|
||||
Models []LanguageModel `json:"models"`
|
||||
}
|
||||
|
||||
// LanguageModel is a language model that can be used for chat.
|
||||
type LanguageModel struct {
|
||||
// ID is used by the provider to identify the LLM.
|
||||
ID string `json:"id"`
|
||||
DisplayName string `json:"display_name"`
|
||||
// Provider is the provider of the LLM. e.g. openai, anthropic, etc.
|
||||
Provider string `json:"provider"`
|
||||
}
|
||||
|
||||
func (c *Client) LanguageModelConfig(ctx context.Context) (LanguageModelConfig, error) {
|
||||
res, err := c.Request(ctx, http.MethodGet, "/api/v2/deployment/llms", nil)
|
||||
if err != nil {
|
||||
return LanguageModelConfig{}, err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
if res.StatusCode != http.StatusOK {
|
||||
return LanguageModelConfig{}, ReadBodyAsError(res)
|
||||
}
|
||||
var llms LanguageModelConfig
|
||||
return llms, json.NewDecoder(res.Body).Decode(&llms)
|
||||
}
|
||||
|
||||
type CryptoKeyFeature string
|
||||
|
||||
const (
|
||||
|
@ -9,6 +9,7 @@ const (
|
||||
ResourceAssignOrgRole RBACResource = "assign_org_role"
|
||||
ResourceAssignRole RBACResource = "assign_role"
|
||||
ResourceAuditLog RBACResource = "audit_log"
|
||||
ResourceChat RBACResource = "chat"
|
||||
ResourceCryptoKey RBACResource = "crypto_key"
|
||||
ResourceDebugInfo RBACResource = "debug_info"
|
||||
ResourceDeploymentConfig RBACResource = "deployment_config"
|
||||
@ -69,6 +70,7 @@ var RBACResourceActions = map[RBACResource][]RBACAction{
|
||||
ResourceAssignOrgRole: {ActionAssign, ActionCreate, ActionDelete, ActionRead, ActionUnassign, ActionUpdate},
|
||||
ResourceAssignRole: {ActionAssign, ActionRead, ActionUnassign},
|
||||
ResourceAuditLog: {ActionCreate, ActionRead},
|
||||
ResourceChat: {ActionCreate, ActionDelete, ActionRead, ActionUpdate},
|
||||
ResourceCryptoKey: {ActionCreate, ActionDelete, ActionRead, ActionUpdate},
|
||||
ResourceDebugInfo: {ActionRead},
|
||||
ResourceDeploymentConfig: {ActionRead, ActionUpdate},
|
||||
|
@ -591,7 +591,7 @@ This resource provides the following fields:
|
||||
- init_script: The script to run on provisioned infrastructure to fetch and start the agent.
|
||||
- token: Set the environment variable CODER_AGENT_TOKEN to this value to authenticate the agent.
|
||||
|
||||
The agent MUST be installed and started using the init_script.
|
||||
The agent MUST be installed and started using the init_script. A utility like curl or wget to fetch the agent binary must exist in the provisioned infrastructure.
|
||||
|
||||
Expose terminal or HTTP applications running in a workspace with:
|
||||
|
||||
@ -711,13 +711,20 @@ resource "google_compute_instance" "dev" {
|
||||
auto_delete = false
|
||||
source = google_compute_disk.root.name
|
||||
}
|
||||
// In order to use google-instance-identity, a service account *must* be provided.
|
||||
service_account {
|
||||
email = data.google_compute_default_service_account.default.email
|
||||
scopes = ["cloud-platform"]
|
||||
}
|
||||
# ONLY FOR WINDOWS:
|
||||
# metadata = {
|
||||
# windows-startup-script-ps1 = coder_agent.main.init_script
|
||||
# }
|
||||
# The startup script runs as root with no $HOME environment set up, so instead of directly
|
||||
# running the agent init script, create a user (with a homedir, default shell and sudo
|
||||
# permissions) and execute the init script as that user.
|
||||
#
|
||||
# The agent MUST be started in here.
|
||||
metadata_startup_script = <<EOMETA
|
||||
#!/usr/bin/env sh
|
||||
set -eux
|
||||
|
Reference in New Issue
Block a user