feat: add custom Extra Headers support for model configurations
Allows users to configure custom HTTP headers per-model via the UI or directly in config.json. These headers are injected into the HTTP requests sent to OpenAI-compatible provider endpoints. - Add `ExtraHeaders` to `ModelConfig` and handle it in models API payload. - Modify `openai_compat.Provider` to accept and inject `extraHeaders`. - Add "Extra Headers" JSON input in web frontend's model forms. - Update README.md with tutorial for configuring custom headers. Co-authored-by: TanLuong <28281768+TanLuong@users.noreply.github.com>
This commit is contained in:
parent
6b503ca745
commit
21c603de9a
10 changed files with 128 additions and 7 deletions
37
README.md
37
README.md
|
|
@ -387,6 +387,43 @@ PicoClaw supports 30+ LLM providers through the `model_list` configuration. Use
|
||||||
|
|
||||||
> \* AWS Bedrock requires build tag: `go build -tags bedrock`. Set `api_base` to a region name (e.g., `us-east-1`) for automatic endpoint resolution across all AWS partitions (aws, aws-cn, aws-us-gov). When using a full endpoint URL instead, you must also configure `AWS_REGION` via environment variable or AWS config/profile.
|
> \* AWS Bedrock requires build tag: `go build -tags bedrock`. Set `api_base` to a region name (e.g., `us-east-1`) for automatic endpoint resolution across all AWS partitions (aws, aws-cn, aws-us-gov). When using a full endpoint URL instead, you must also configure `AWS_REGION` via environment variable or AWS config/profile.
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><b>Custom Headers (Web UI & config.json)</b></summary>
|
||||||
|
|
||||||
|
You can pass custom HTTP headers to your model API calls (useful for custom gateways, proxies, or specific provider features).
|
||||||
|
|
||||||
|
**In the Web UI:**
|
||||||
|
1. Go to the **Models** page and click **Add Model** or edit an existing one.
|
||||||
|
2. Open the **Advanced** section.
|
||||||
|
3. In the **Extra Headers** field, enter your custom headers in valid JSON format, for example:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"X-My-Header": "value",
|
||||||
|
"Authorization": "Bearer my-custom-token"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
4. Save the model.
|
||||||
|
|
||||||
|
**Directly in `config.json`:**
|
||||||
|
Add the `extra_headers` field to your model configuration:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"model_list": [
|
||||||
|
{
|
||||||
|
"model_name": "custom-header-model",
|
||||||
|
"model": "openai/gpt-4o",
|
||||||
|
"api_base": "https://api.myproxy.com/v1",
|
||||||
|
"api_key": "sk-123",
|
||||||
|
"extra_headers": {
|
||||||
|
"X-My-Header": "value"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary><b>Local deployment (Ollama, vLLM, etc.)</b></summary>
|
<summary><b>Local deployment (Ollama, vLLM, etc.)</b></summary>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -856,6 +856,7 @@ type ModelConfig struct {
|
||||||
RequestTimeout int `json:"request_timeout,omitempty"`
|
RequestTimeout int `json:"request_timeout,omitempty"`
|
||||||
ThinkingLevel string `json:"thinking_level,omitempty"` // Extended thinking: off|low|medium|high|xhigh|adaptive
|
ThinkingLevel string `json:"thinking_level,omitempty"` // Extended thinking: off|low|medium|high|xhigh|adaptive
|
||||||
ExtraBody map[string]any `json:"extra_body,omitempty"` // Additional fields to inject into request body
|
ExtraBody map[string]any `json:"extra_body,omitempty"` // Additional fields to inject into request body
|
||||||
|
ExtraHeaders map[string]string `json:"extra_headers,omitempty"` // Additional headers to inject into request
|
||||||
|
|
||||||
// from security
|
// from security
|
||||||
secModelName string
|
secModelName string
|
||||||
|
|
@ -2045,6 +2046,7 @@ func expandMultiKeyModels(models []*ModelConfig) []*ModelConfig {
|
||||||
RequestTimeout: m.RequestTimeout,
|
RequestTimeout: m.RequestTimeout,
|
||||||
ThinkingLevel: m.ThinkingLevel,
|
ThinkingLevel: m.ThinkingLevel,
|
||||||
ExtraBody: m.ExtraBody,
|
ExtraBody: m.ExtraBody,
|
||||||
|
ExtraHeaders: m.ExtraHeaders,
|
||||||
apiKeys: []string{keys[0]},
|
apiKeys: []string{keys[0]},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -99,6 +99,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
|
||||||
cfg.MaxTokensField,
|
cfg.MaxTokensField,
|
||||||
cfg.RequestTimeout,
|
cfg.RequestTimeout,
|
||||||
cfg.ExtraBody,
|
cfg.ExtraBody,
|
||||||
|
cfg.ExtraHeaders,
|
||||||
), modelID, nil
|
), modelID, nil
|
||||||
|
|
||||||
case "azure", "azure-openai":
|
case "azure", "azure-openai":
|
||||||
|
|
@ -193,6 +194,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
|
||||||
cfg.MaxTokensField,
|
cfg.MaxTokensField,
|
||||||
cfg.RequestTimeout,
|
cfg.RequestTimeout,
|
||||||
cfg.ExtraBody,
|
cfg.ExtraBody,
|
||||||
|
cfg.ExtraHeaders,
|
||||||
), modelID, nil
|
), modelID, nil
|
||||||
|
|
||||||
case "minimax":
|
case "minimax":
|
||||||
|
|
@ -218,6 +220,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
|
||||||
cfg.MaxTokensField,
|
cfg.MaxTokensField,
|
||||||
cfg.RequestTimeout,
|
cfg.RequestTimeout,
|
||||||
extraBody,
|
extraBody,
|
||||||
|
cfg.ExtraHeaders,
|
||||||
), modelID, nil
|
), modelID, nil
|
||||||
|
|
||||||
case "anthropic":
|
case "anthropic":
|
||||||
|
|
@ -244,6 +247,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
|
||||||
cfg.MaxTokensField,
|
cfg.MaxTokensField,
|
||||||
cfg.RequestTimeout,
|
cfg.RequestTimeout,
|
||||||
cfg.ExtraBody,
|
cfg.ExtraBody,
|
||||||
|
cfg.ExtraHeaders,
|
||||||
), modelID, nil
|
), modelID, nil
|
||||||
|
|
||||||
case "anthropic-messages":
|
case "anthropic-messages":
|
||||||
|
|
|
||||||
|
|
@ -24,13 +24,14 @@ func NewHTTPProvider(apiKey, apiBase, proxy string) *HTTPProvider {
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewHTTPProviderWithMaxTokensField(apiKey, apiBase, proxy, maxTokensField string) *HTTPProvider {
|
func NewHTTPProviderWithMaxTokensField(apiKey, apiBase, proxy, maxTokensField string) *HTTPProvider {
|
||||||
return NewHTTPProviderWithMaxTokensFieldAndRequestTimeout(apiKey, apiBase, proxy, maxTokensField, 0, nil)
|
return NewHTTPProviderWithMaxTokensFieldAndRequestTimeout(apiKey, apiBase, proxy, maxTokensField, 0, nil, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewHTTPProviderWithMaxTokensFieldAndRequestTimeout(
|
func NewHTTPProviderWithMaxTokensFieldAndRequestTimeout(
|
||||||
apiKey, apiBase, proxy, maxTokensField string,
|
apiKey, apiBase, proxy, maxTokensField string,
|
||||||
requestTimeoutSeconds int,
|
requestTimeoutSeconds int,
|
||||||
extraBody map[string]any,
|
extraBody map[string]any,
|
||||||
|
extraHeaders map[string]string,
|
||||||
) *HTTPProvider {
|
) *HTTPProvider {
|
||||||
return &HTTPProvider{
|
return &HTTPProvider{
|
||||||
delegate: openai_compat.NewProvider(
|
delegate: openai_compat.NewProvider(
|
||||||
|
|
@ -40,6 +41,7 @@ func NewHTTPProviderWithMaxTokensFieldAndRequestTimeout(
|
||||||
openai_compat.WithMaxTokensField(maxTokensField),
|
openai_compat.WithMaxTokensField(maxTokensField),
|
||||||
openai_compat.WithRequestTimeout(time.Duration(requestTimeoutSeconds)*time.Second),
|
openai_compat.WithRequestTimeout(time.Duration(requestTimeoutSeconds)*time.Second),
|
||||||
openai_compat.WithExtraBody(extraBody),
|
openai_compat.WithExtraBody(extraBody),
|
||||||
|
openai_compat.WithExtraHeaders(extraHeaders),
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,7 @@ type Provider struct {
|
||||||
maxTokensField string // Field name for max tokens (e.g., "max_completion_tokens" for o1/glm models)
|
maxTokensField string // Field name for max tokens (e.g., "max_completion_tokens" for o1/glm models)
|
||||||
httpClient *http.Client
|
httpClient *http.Client
|
||||||
extraBody map[string]any // Additional fields to inject into request body
|
extraBody map[string]any // Additional fields to inject into request body
|
||||||
|
extraHeaders map[string]string // Additional headers to inject into request
|
||||||
}
|
}
|
||||||
|
|
||||||
type Option func(*Provider)
|
type Option func(*Provider)
|
||||||
|
|
@ -62,6 +63,12 @@ func WithExtraBody(extraBody map[string]any) Option {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func WithExtraHeaders(extraHeaders map[string]string) Option {
|
||||||
|
return func(p *Provider) {
|
||||||
|
p.extraHeaders = extraHeaders
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func NewProvider(apiKey, apiBase, proxy string, opts ...Option) *Provider {
|
func NewProvider(apiKey, apiBase, proxy string, opts ...Option) *Provider {
|
||||||
p := &Provider{
|
p := &Provider{
|
||||||
apiKey: apiKey,
|
apiKey: apiKey,
|
||||||
|
|
@ -183,6 +190,9 @@ func (p *Provider) Chat(
|
||||||
if p.apiKey != "" {
|
if p.apiKey != "" {
|
||||||
req.Header.Set("Authorization", "Bearer "+p.apiKey)
|
req.Header.Set("Authorization", "Bearer "+p.apiKey)
|
||||||
}
|
}
|
||||||
|
for k, v := range p.extraHeaders {
|
||||||
|
req.Header.Set(k, v)
|
||||||
|
}
|
||||||
|
|
||||||
resp, err := p.httpClient.Do(req)
|
resp, err := p.httpClient.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -229,6 +239,9 @@ func (p *Provider) ChatStream(
|
||||||
if p.apiKey != "" {
|
if p.apiKey != "" {
|
||||||
req.Header.Set("Authorization", "Bearer "+p.apiKey)
|
req.Header.Set("Authorization", "Bearer "+p.apiKey)
|
||||||
}
|
}
|
||||||
|
for k, v := range p.extraHeaders {
|
||||||
|
req.Header.Set(k, v)
|
||||||
|
}
|
||||||
|
|
||||||
// Use a client without Timeout for streaming — the http.Client.Timeout covers
|
// Use a client without Timeout for streaming — the http.Client.Timeout covers
|
||||||
// the entire request lifecycle including body reads, which would kill long streams.
|
// the entire request lifecycle including body reads, which would kill long streams.
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,7 @@ type modelResponse struct {
|
||||||
RequestTimeout int `json:"request_timeout,omitempty"`
|
RequestTimeout int `json:"request_timeout,omitempty"`
|
||||||
ThinkingLevel string `json:"thinking_level,omitempty"`
|
ThinkingLevel string `json:"thinking_level,omitempty"`
|
||||||
ExtraBody map[string]any `json:"extra_body,omitempty"`
|
ExtraBody map[string]any `json:"extra_body,omitempty"`
|
||||||
|
ExtraHeaders map[string]string `json:"extra_headers,omitempty"`
|
||||||
// Meta
|
// Meta
|
||||||
Configured bool `json:"configured"`
|
Configured bool `json:"configured"`
|
||||||
IsDefault bool `json:"is_default"`
|
IsDefault bool `json:"is_default"`
|
||||||
|
|
@ -84,6 +85,7 @@ func (h *Handler) handleListModels(w http.ResponseWriter, r *http.Request) {
|
||||||
RequestTimeout: m.RequestTimeout,
|
RequestTimeout: m.RequestTimeout,
|
||||||
ThinkingLevel: m.ThinkingLevel,
|
ThinkingLevel: m.ThinkingLevel,
|
||||||
ExtraBody: m.ExtraBody,
|
ExtraBody: m.ExtraBody,
|
||||||
|
ExtraHeaders: m.ExtraHeaders,
|
||||||
Configured: configured[i],
|
Configured: configured[i],
|
||||||
IsDefault: m.ModelName == defaultModel,
|
IsDefault: m.ModelName == defaultModel,
|
||||||
})
|
})
|
||||||
|
|
@ -205,6 +207,9 @@ func (h *Handler) handleUpdateModel(w http.ResponseWriter, r *http.Request) {
|
||||||
if mc.ExtraBody == nil {
|
if mc.ExtraBody == nil {
|
||||||
mc.ExtraBody = cfg.ModelList[idx].ExtraBody
|
mc.ExtraBody = cfg.ModelList[idx].ExtraBody
|
||||||
}
|
}
|
||||||
|
if mc.ExtraHeaders == nil {
|
||||||
|
mc.ExtraHeaders = cfg.ModelList[idx].ExtraHeaders
|
||||||
|
}
|
||||||
|
|
||||||
cfg.ModelList[idx] = &mc.ModelConfig
|
cfg.ModelList[idx] = &mc.ModelConfig
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ export interface ModelInfo {
|
||||||
request_timeout?: number
|
request_timeout?: number
|
||||||
thinking_level?: string
|
thinking_level?: string
|
||||||
extra_body?: Record<string, unknown>
|
extra_body?: Record<string, unknown>
|
||||||
|
extra_headers?: Record<string, string>
|
||||||
// Meta
|
// Meta
|
||||||
configured: boolean
|
configured: boolean
|
||||||
is_default: boolean
|
is_default: boolean
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,7 @@ interface AddForm {
|
||||||
maxTokensField: string
|
maxTokensField: string
|
||||||
requestTimeout: string
|
requestTimeout: string
|
||||||
thinkingLevel: string
|
thinkingLevel: string
|
||||||
|
extraHeaders: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const EMPTY_ADD_FORM: AddForm = {
|
const EMPTY_ADD_FORM: AddForm = {
|
||||||
|
|
@ -49,6 +50,7 @@ const EMPTY_ADD_FORM: AddForm = {
|
||||||
maxTokensField: "",
|
maxTokensField: "",
|
||||||
requestTimeout: "",
|
requestTimeout: "",
|
||||||
thinkingLevel: "",
|
thinkingLevel: "",
|
||||||
|
extraHeaders: "",
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AddModelSheetProps {
|
interface AddModelSheetProps {
|
||||||
|
|
@ -95,6 +97,16 @@ export function AddModelSheet({
|
||||||
errors.modelName = t("models.add.errorDuplicateModelName")
|
errors.modelName = t("models.add.errorDuplicateModelName")
|
||||||
}
|
}
|
||||||
if (!form.model.trim()) errors.model = t("models.add.errorRequired")
|
if (!form.model.trim()) errors.model = t("models.add.errorRequired")
|
||||||
|
if (form.extraHeaders.trim()) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(form.extraHeaders.trim())
|
||||||
|
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
||||||
|
errors.extraHeaders = "Must be a valid JSON object"
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
errors.extraHeaders = "Invalid JSON format"
|
||||||
|
}
|
||||||
|
}
|
||||||
setFieldErrors(errors)
|
setFieldErrors(errors)
|
||||||
return Object.keys(errors).length === 0
|
return Object.keys(errors).length === 0
|
||||||
}
|
}
|
||||||
|
|
@ -129,6 +141,7 @@ export function AddModelSheet({
|
||||||
? Number(form.requestTimeout)
|
? Number(form.requestTimeout)
|
||||||
: undefined,
|
: undefined,
|
||||||
thinking_level: form.thinkingLevel.trim() || undefined,
|
thinking_level: form.thinkingLevel.trim() || undefined,
|
||||||
|
extra_headers: form.extraHeaders.trim() ? JSON.parse(form.extraHeaders.trim()) : undefined,
|
||||||
})
|
})
|
||||||
if (setAsDefault) {
|
if (setAsDefault) {
|
||||||
await setDefaultModel(modelName)
|
await setDefaultModel(modelName)
|
||||||
|
|
@ -305,6 +318,21 @@ export function AddModelSheet({
|
||||||
placeholder="max_completion_tokens"
|
placeholder="max_completion_tokens"
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
|
<Field
|
||||||
|
label={t("models.field.extraHeaders")}
|
||||||
|
hint={t("models.field.extraHeadersHint")}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
value={form.extraHeaders}
|
||||||
|
onChange={setField("extraHeaders")}
|
||||||
|
placeholder='{"X-My-Header": "value"}'
|
||||||
|
aria-invalid={!!fieldErrors.extraHeaders}
|
||||||
|
/>
|
||||||
|
{fieldErrors.extraHeaders && (
|
||||||
|
<p className="text-destructive text-xs">{fieldErrors.extraHeaders}</p>
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
</AdvancedSection>
|
</AdvancedSection>
|
||||||
|
|
||||||
{serverError && (
|
{serverError && (
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,7 @@ interface EditForm {
|
||||||
maxTokensField: string
|
maxTokensField: string
|
||||||
requestTimeout: string
|
requestTimeout: string
|
||||||
thinkingLevel: string
|
thinkingLevel: string
|
||||||
|
extraHeaders: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface EditModelSheetProps {
|
interface EditModelSheetProps {
|
||||||
|
|
@ -59,6 +60,7 @@ export function EditModelSheet({
|
||||||
maxTokensField: "",
|
maxTokensField: "",
|
||||||
requestTimeout: "",
|
requestTimeout: "",
|
||||||
thinkingLevel: "",
|
thinkingLevel: "",
|
||||||
|
extraHeaders: "",
|
||||||
})
|
})
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [setAsDefault, setSetAsDefault] = useState(false)
|
const [setAsDefault, setSetAsDefault] = useState(false)
|
||||||
|
|
@ -79,6 +81,7 @@ export function EditModelSheet({
|
||||||
? String(model.request_timeout)
|
? String(model.request_timeout)
|
||||||
: "",
|
: "",
|
||||||
thinkingLevel: model.thinking_level ?? "",
|
thinkingLevel: model.thinking_level ?? "",
|
||||||
|
extraHeaders: model.extra_headers ? JSON.stringify(model.extra_headers) : "",
|
||||||
})
|
})
|
||||||
setSetAsDefault(model.is_default)
|
setSetAsDefault(model.is_default)
|
||||||
setError("")
|
setError("")
|
||||||
|
|
@ -91,6 +94,18 @@ export function EditModelSheet({
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
if (!model) return
|
if (!model) return
|
||||||
|
if (form.extraHeaders.trim()) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(form.extraHeaders.trim())
|
||||||
|
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
||||||
|
setError("Extra headers must be a valid JSON object")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setError("Extra headers must be valid JSON format")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
setSaving(true)
|
setSaving(true)
|
||||||
setError("")
|
setError("")
|
||||||
try {
|
try {
|
||||||
|
|
@ -109,6 +124,7 @@ export function EditModelSheet({
|
||||||
? Number(form.requestTimeout)
|
? Number(form.requestTimeout)
|
||||||
: undefined,
|
: undefined,
|
||||||
thinking_level: form.thinkingLevel || undefined,
|
thinking_level: form.thinkingLevel || undefined,
|
||||||
|
extra_headers: form.extraHeaders.trim() ? JSON.parse(form.extraHeaders.trim()) : undefined,
|
||||||
})
|
})
|
||||||
if (setAsDefault && !model.is_default) {
|
if (setAsDefault && !model.is_default) {
|
||||||
await setDefaultModel(model.model_name)
|
await setDefaultModel(model.model_name)
|
||||||
|
|
@ -273,6 +289,17 @@ export function EditModelSheet({
|
||||||
placeholder="max_completion_tokens"
|
placeholder="max_completion_tokens"
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
|
<Field
|
||||||
|
label={t("models.field.extraHeaders")}
|
||||||
|
hint={t("models.field.extraHeadersHint")}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
value={form.extraHeaders}
|
||||||
|
onChange={setField("extraHeaders")}
|
||||||
|
placeholder='{"X-My-Header": "value"}'
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
</AdvancedSection>
|
</AdvancedSection>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
|
|
|
||||||
|
|
@ -208,7 +208,9 @@
|
||||||
"thinkingLevel": "Thinking Level",
|
"thinkingLevel": "Thinking Level",
|
||||||
"thinkingLevelHint": "Extended thinking budget: off, low, medium, high, xhigh, adaptive.",
|
"thinkingLevelHint": "Extended thinking budget: off, low, medium, high, xhigh, adaptive.",
|
||||||
"maxTokensField": "Max Tokens Field",
|
"maxTokensField": "Max Tokens Field",
|
||||||
"maxTokensFieldHint": "Override the request field name for max tokens, e.g. max_completion_tokens."
|
"maxTokensFieldHint": "Override the request field name for max tokens, e.g. max_completion_tokens.",
|
||||||
|
"extraHeaders": "Extra Headers",
|
||||||
|
"extraHeadersHint": "Custom HTTP headers in JSON format, e.g. {\"X-My-Header\": \"value\"}"
|
||||||
},
|
},
|
||||||
"edit": {
|
"edit": {
|
||||||
"title": "Configure {{name}}",
|
"title": "Configure {{name}}",
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue